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

This commit is contained in:
weisd
2026-03-24 17:29:33 +08:00
committed by GitHub
parent 8aa59b12cb
commit 28f57b228c
60 changed files with 15509 additions and 412 deletions
Generated
+14 -1
View File
@@ -3081,14 +3081,19 @@ name = "e2e_test"
version = "0.0.5"
dependencies = [
"anyhow",
"astral-tokio-tar",
"async-compression",
"async-trait",
"aws-config",
"aws-sdk-s3",
"aws-smithy-http-client",
"base64 0.22.1",
"bytes",
"chrono",
"flatbuffers",
"flate2",
"futures",
"http 1.4.0",
"md5",
"rand 0.10.0",
"rcgen",
@@ -3100,18 +3105,24 @@ dependencies = [
"rustfs-lock",
"rustfs-madmin",
"rustfs-protos",
"rustfs-signer",
"rustls",
"s3s",
"serde",
"serde_json",
"serial_test",
"sha2 0.11.0-rc.5",
"suppaftp",
"time",
"tokio",
"tokio-stream",
"tonic",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
"walkdir",
"zstd",
]
[[package]]
@@ -7325,6 +7336,7 @@ dependencies = [
"rustfs-s3select-api",
"rustfs-s3select-query",
"rustfs-scanner",
"rustfs-signer",
"rustfs-targets",
"rustfs-trusted-proxies",
"rustfs-utils",
@@ -7728,6 +7740,7 @@ version = "0.0.5"
dependencies = [
"anyhow",
"aws-sdk-s3",
"aws-smithy-http-client",
"clap",
"mime_guess",
"rmcp",
@@ -8329,7 +8342,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s3s"
version = "0.14.0-dev"
source = "git+https://github.com/rustfs/s3s?rev=d9556e3c0036bd3f2b330966009cbaa5aebf19a3#d9556e3c0036bd3f2b330966009cbaa5aebf19a3"
source = "git+https://github.com/rustfs/s3s?rev=b296762bc9e7fa608f1bc44f5cd625d606e0dd31#b296762bc9e7fa608f1bc44f5cd625d606e0dd31"
dependencies = [
"arc-swap",
"arrayvec",
+1 -1
View File
@@ -243,7 +243,7 @@ rumqttc = { version = "0.25.1" }
rustix = { version = "1.1.4", features = ["fs"] }
rust-embed = { version = "8.11.0" }
rustc-hash = { version = "2.1.1" }
s3s = { git = "https://github.com/rustfs/s3s", rev = "d9556e3c0036bd3f2b330966009cbaa5aebf19a3", features = ["minio"] }
s3s = { git = "https://github.com/rustfs/s3s", rev = "b296762bc9e7fa608f1bc44f5cd625d606e0dd31", features = ["minio"] }
serial_test = "3.4.0"
shadow-rs = { version = "1.7.1", default-features = false }
siphasher = "1.0.2"
+12 -1
View File
@@ -46,17 +46,28 @@ bytes.workspace = true
serial_test = { workspace = true }
aws-sdk-s3.workspace = true
aws-config = { workspace = true }
aws-smithy-http-client.workspace = true
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
async-trait = { workspace = true }
flate2.workspace = true
http.workspace = true
reqwest = { workspace = true }
rustfs-signer.workspace = true
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
urlencoding.workspace = true
walkdir.workspace = true
base64 = { workspace = true }
rand = { workspace = true }
chrono = { workspace = true }
md5 = { workspace = true }
sha2 = { workspace = true }
astral-tokio-tar = { workspace = true }
s3s.workspace = true
zstd.workspace = true
time.workspace = true
suppaftp = { workspace = true, features = ["tokio", "rustls-aws-lc-rs"] }
rcgen.workspace = true
anyhow.workspace = true
rustls.workspace = true
rustls.workspace = true
+2 -2
View File
@@ -16,7 +16,7 @@
//! Verifies that anonymous access works correctly with bucket policies
//! when PublicAccessBlock configuration is missing or explicitly set.
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
use serial_test::serial;
use tracing::info;
@@ -67,7 +67,7 @@ async fn anonymous_get_object(
key: &str,
) -> Result<reqwest::Response, reqwest::Error> {
let url = format!("{}/{}/{}", env.url, bucket_name, key);
reqwest::Client::new().get(&url).send().await
local_http_client().get(&url).send().await
}
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
+544
View File
@@ -0,0 +1,544 @@
// 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.
//! End-to-end tests for S3 dummy-compat bucket APIs.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
RequestPaymentConfiguration, WebsiteConfiguration,
};
use serial_test::serial;
use std::path::PathBuf;
use std::process::Command;
use tracing::info;
fn awscurl_binary_path() -> PathBuf {
std::env::var_os("AWSCURL_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("awscurl"))
}
fn awscurl_available() -> bool {
Command::new(awscurl_binary_path()).arg("--version").output().is_ok()
}
fn execute_s3_awscurl(
method: &str,
url: &str,
access_key: &str,
secret_key: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let output = Command::new(awscurl_binary_path())
.args([
"--service",
"s3",
"--region",
"us-east-1",
"--access_key",
access_key,
"--secret_key",
secret_key,
"-i",
"-X",
method,
url,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("awscurl failed: stderr='{stderr}', stdout='{stdout}'").into());
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn parse_status(raw: &str) -> Option<u16> {
raw.lines()
.filter_map(|line| {
if line.starts_with("HTTP/") {
line.split_whitespace().nth(1)?.parse::<u16>().ok()
} else {
None
}
})
.next_back()
}
fn parse_body(raw: &str) -> String {
if let Some(pos) = raw.rfind("\r\n\r\n") {
return raw[pos + 4..].to_string();
}
if let Some(pos) = raw.rfind("\n\n") {
return raw[pos + 2..].to_string();
}
String::new()
}
fn parse_headers(raw: &str) -> String {
let start = raw.rfind("HTTP/").unwrap_or(0);
let tail = &raw[start..];
if let Some(pos) = tail.find("\r\n\r\n") {
return tail[..pos].to_string();
}
if let Some(pos) = tail.find("\n\n") {
return tail[..pos].to_string();
}
tail.to_string()
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_compatibility_endpoints() {
init_logging();
info!("Starting test: dummy-compat bucket APIs should match S3-compatible behavior");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "test-get-bucket-logging";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
let result = client.get_bucket_logging().bucket(bucket).send().await;
assert!(
result.is_ok(),
"GetBucketLogging should return success for existing bucket, got: {:?}",
result.err()
);
let output = result.unwrap();
assert!(
output.logging_enabled().is_none(),
"Default GetBucketLogging should return empty logging configuration"
);
let put_logging = client
.put_bucket_logging()
.bucket(bucket)
.bucket_logging_status(
BucketLoggingStatus::builder()
.logging_enabled(
LoggingEnabled::builder()
.target_bucket(bucket)
.target_prefix("logs/")
.build()
.expect("failed to build LoggingEnabled"),
)
.build(),
)
.send()
.await;
assert!(
put_logging.is_ok(),
"PutBucketLogging should return success for existing bucket, got: {:?}",
put_logging.err()
);
let output_after_put = client
.get_bucket_logging()
.bucket(bucket)
.send()
.await
.expect("GetBucketLogging should succeed after PutBucketLogging");
let logging_after_put = output_after_put
.logging_enabled()
.expect("GetBucketLogging should return persisted logging_enabled");
assert_eq!(
logging_after_put.target_bucket(),
bucket,
"GetBucketLogging should preserve target bucket"
);
assert_eq!(
logging_after_put.target_prefix(),
"logs/",
"GetBucketLogging should preserve target prefix"
);
let accelerate = client
.get_bucket_accelerate_configuration()
.bucket(bucket)
.send()
.await
.expect("GetBucketAccelerateConfiguration should succeed");
assert!(
accelerate.status().is_none(),
"Default GetBucketAccelerateConfiguration should return empty status"
);
let payment = client
.get_bucket_request_payment()
.bucket(bucket)
.send()
.await
.expect("GetBucketRequestPayment should succeed");
assert_eq!(
payment.payer().map(|p| p.as_str()),
Some("BucketOwner"),
"GetBucketRequestPayment should return BucketOwner by default"
);
let put_accelerate = client
.put_bucket_accelerate_configuration()
.bucket(bucket)
.accelerate_configuration(
AccelerateConfiguration::builder()
.status(BucketAccelerateStatus::Suspended)
.build(),
)
.send()
.await;
assert!(
put_accelerate.is_ok(),
"PutBucketAccelerateConfiguration should return success for existing bucket, got: {:?}",
put_accelerate.err()
);
let put_request_payment = client
.put_bucket_request_payment()
.bucket(bucket)
.request_payment_configuration(
RequestPaymentConfiguration::builder()
.payer(Payer::Requester)
.build()
.expect("failed to build RequestPaymentConfiguration"),
)
.send()
.await;
assert!(
put_request_payment.is_ok(),
"PutBucketRequestPayment should return success for existing bucket, got: {:?}",
put_request_payment.err()
);
let accelerate_after_put = client
.get_bucket_accelerate_configuration()
.bucket(bucket)
.send()
.await
.expect("GetBucketAccelerateConfiguration should succeed after put");
assert_eq!(
accelerate_after_put.status().map(|s| s.as_str()),
Some("Suspended"),
"GetBucketAccelerateConfiguration should preserve put status"
);
let payment_after_put = client
.get_bucket_request_payment()
.bucket(bucket)
.send()
.await
.expect("GetBucketRequestPayment should succeed after put");
assert_eq!(
payment_after_put.payer().map(|p| p.as_str()),
Some("Requester"),
"GetBucketRequestPayment should preserve put payer"
);
let put_website = client
.put_bucket_website()
.bucket(bucket)
.website_configuration(
WebsiteConfiguration::builder()
.index_document(
IndexDocument::builder()
.suffix("index.html")
.build()
.expect("failed to build IndexDocument"),
)
.build(),
)
.send()
.await;
assert!(
put_website.is_ok(),
"PutBucketWebsite should return success for existing bucket, got: {:?}",
put_website.err()
);
let website = client.get_bucket_website().bucket(bucket).send().await;
assert!(website.is_ok(), "GetBucketWebsite should return persisted website configuration");
let website_output = website.unwrap();
assert_eq!(
website_output.index_document().map(|doc| doc.suffix()),
Some("index.html"),
"GetBucketWebsite should preserve index document suffix"
);
client
.delete_bucket_website()
.bucket(bucket)
.send()
.await
.expect("DeleteBucketWebsite should return success");
let website_after_delete = client.get_bucket_website().bucket(bucket).send().await;
assert!(
website_after_delete.is_err(),
"GetBucketWebsite should return NoSuchWebsiteConfiguration after deletion"
);
let website_err = website_after_delete.err().unwrap();
let website_code = website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(website_code, Some("NoSuchWebsiteConfiguration")),
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
website_code,
website_err
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_compatibility_endpoints_no_such_bucket() {
init_logging();
info!("Starting test: dummy-compat bucket APIs should return NoSuchBucket for missing bucket");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let missing_bucket = "test-dummy-bucket-missing";
let get_logging = client.get_bucket_logging().bucket(missing_bucket).send().await;
assert!(get_logging.is_err(), "GetBucketLogging should fail for missing bucket");
let get_logging_err = get_logging.err().unwrap();
let get_logging_code = get_logging_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_logging_code, Some("NoSuchBucket")),
"Unexpected GetBucketLogging error code: {:?}, err: {:?}",
get_logging_code,
get_logging_err
);
let put_logging = client
.put_bucket_logging()
.bucket(missing_bucket)
.bucket_logging_status(BucketLoggingStatus::builder().build())
.send()
.await;
assert!(put_logging.is_err(), "PutBucketLogging should fail for missing bucket");
let put_logging_err = put_logging.err().unwrap();
let put_logging_code = put_logging_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_logging_code, Some("NoSuchBucket")),
"Unexpected PutBucketLogging error code: {:?}, err: {:?}",
put_logging_code,
put_logging_err
);
let get_accelerate = client
.get_bucket_accelerate_configuration()
.bucket(missing_bucket)
.send()
.await;
assert!(get_accelerate.is_err(), "GetBucketAccelerateConfiguration should fail for missing bucket");
let get_accelerate_err = get_accelerate.err().unwrap();
let get_accelerate_code = get_accelerate_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_accelerate_code, Some("NoSuchBucket")),
"Unexpected GetBucketAccelerateConfiguration error code: {:?}, err: {:?}",
get_accelerate_code,
get_accelerate_err
);
let get_request_payment = client.get_bucket_request_payment().bucket(missing_bucket).send().await;
assert!(get_request_payment.is_err(), "GetBucketRequestPayment should fail for missing bucket");
let get_request_payment_err = get_request_payment.err().unwrap();
let get_request_payment_code = get_request_payment_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_request_payment_code, Some("NoSuchBucket")),
"Unexpected GetBucketRequestPayment error code: {:?}, err: {:?}",
get_request_payment_code,
get_request_payment_err
);
let put_accelerate = client
.put_bucket_accelerate_configuration()
.bucket(missing_bucket)
.accelerate_configuration(
AccelerateConfiguration::builder()
.status(BucketAccelerateStatus::Suspended)
.build(),
)
.send()
.await;
assert!(put_accelerate.is_err(), "PutBucketAccelerateConfiguration should fail for missing bucket");
let put_accelerate_err = put_accelerate.err().unwrap();
let put_accelerate_code = put_accelerate_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_accelerate_code, Some("NoSuchBucket")),
"Unexpected PutBucketAccelerateConfiguration error code: {:?}, err: {:?}",
put_accelerate_code,
put_accelerate_err
);
let put_request_payment = client
.put_bucket_request_payment()
.bucket(missing_bucket)
.request_payment_configuration(
RequestPaymentConfiguration::builder()
.payer(Payer::BucketOwner)
.build()
.expect("failed to build RequestPaymentConfiguration"),
)
.send()
.await;
assert!(put_request_payment.is_err(), "PutBucketRequestPayment should fail for missing bucket");
let put_request_payment_err = put_request_payment.err().unwrap();
let put_request_payment_code = put_request_payment_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_request_payment_code, Some("NoSuchBucket")),
"Unexpected PutBucketRequestPayment error code: {:?}, err: {:?}",
put_request_payment_code,
put_request_payment_err
);
let put_website = client
.put_bucket_website()
.bucket(missing_bucket)
.website_configuration(
WebsiteConfiguration::builder()
.index_document(
IndexDocument::builder()
.suffix("index.html")
.build()
.expect("failed to build IndexDocument"),
)
.build(),
)
.send()
.await;
assert!(put_website.is_err(), "PutBucketWebsite should fail for missing bucket");
let put_website_err = put_website.err().unwrap();
let put_website_code = put_website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(put_website_code, Some("NoSuchBucket")),
"Unexpected PutBucketWebsite error code: {:?}, err: {:?}",
put_website_code,
put_website_err
);
let get_website = client.get_bucket_website().bucket(missing_bucket).send().await;
assert!(get_website.is_err(), "GetBucketWebsite should fail for missing bucket");
let get_website_err = get_website.err().unwrap();
let get_website_code = get_website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(get_website_code, Some("NoSuchBucket")),
"Unexpected GetBucketWebsite error code: {:?}, err: {:?}",
get_website_code,
get_website_err
);
let delete_website = client.delete_bucket_website().bucket(missing_bucket).send().await;
assert!(delete_website.is_err(), "DeleteBucketWebsite should fail for missing bucket");
let delete_website_err = delete_website.err().unwrap();
let delete_website_code = delete_website_err.as_service_error().and_then(|e| e.code());
assert!(
matches!(delete_website_code, Some("NoSuchBucket")),
"Unexpected DeleteBucketWebsite error code: {:?}, err: {:?}",
delete_website_code,
delete_website_err
);
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_dummy_bucket_endpoints_http_contracts() {
init_logging();
info!("Starting test: dummy-compat bucket API HTTP contracts");
if !awscurl_available() {
info!("Skipping test_dummy_bucket_endpoints_http_contracts: awscurl binary not found");
return;
}
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = env.create_s3_client();
let bucket = "test-dummy-bucket-http-contracts";
client
.create_bucket()
.bucket(bucket)
.send()
.await
.expect("Failed to create bucket");
let logging_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?logging=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketLogging HTTP request failed");
assert_eq!(parse_status(&logging_raw), Some(200), "GetBucketLogging should return 200");
let logging_body = parse_body(&logging_raw);
assert!(
logging_body.contains("<BucketLoggingStatus"),
"GetBucketLogging response should contain BucketLoggingStatus XML, got: {logging_body}"
);
let accel_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?accelerate=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketAccelerateConfiguration HTTP request failed");
assert_eq!(parse_status(&accel_raw), Some(200), "GetBucketAccelerateConfiguration should return 200");
let accel_body = parse_body(&accel_raw);
assert!(
accel_body.contains("<AccelerateConfiguration"),
"GetBucketAccelerateConfiguration response should contain AccelerateConfiguration XML, got: {accel_body}"
);
let payment_raw =
execute_s3_awscurl("GET", &format!("{}/{bucket}?requestPayment=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketRequestPayment HTTP request failed");
assert_eq!(parse_status(&payment_raw), Some(200), "GetBucketRequestPayment should return 200");
let payment_body = parse_body(&payment_raw);
assert!(
payment_body.contains("<Payer>BucketOwner</Payer>"),
"GetBucketRequestPayment should return BucketOwner payer, got: {payment_body}"
);
let website_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?website=", env.url), &env.access_key, &env.secret_key)
.expect("GetBucketWebsite HTTP request failed");
assert_eq!(
parse_status(&website_raw),
Some(404),
"GetBucketWebsite should return 404 when website config is absent"
);
let website_content_type = parse_headers(&website_raw).to_ascii_lowercase();
assert!(
website_content_type.contains("content-type:") && website_content_type.contains("xml"),
"GetBucketWebsite error response should be XML, got content-type: {website_content_type}"
);
let website_body = parse_body(&website_raw);
assert!(
website_body.contains("<Code>NoSuchWebsiteConfiguration</Code>"),
"GetBucketWebsite should return NoSuchWebsiteConfiguration code, got: {website_body}"
);
let delete_raw =
execute_s3_awscurl("DELETE", &format!("{}/{bucket}?website=", env.url), &env.access_key, &env.secret_key)
.expect("DeleteBucketWebsite HTTP request failed");
assert_eq!(parse_status(&delete_raw), Some(204), "DeleteBucketWebsite should return 204");
env.stop_server();
}
}
@@ -54,6 +54,10 @@ fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
#[serial]
async fn test_bucket_policy_authenticated_user() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if !crate::common::awscurl_available() {
info!("Skipping test_bucket_policy_authenticated_user because awscurl is not available");
return Ok(());
}
info!("Starting test_bucket_policy_authenticated_user...");
let mut env = RustFSTestEnvironment::new().await?;
+221 -61
View File
@@ -23,7 +23,11 @@
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::{Client, Config};
use std::path::PathBuf;
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use reqwest::Client as HttpClient;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::Once;
use std::time::Duration;
@@ -32,11 +36,29 @@ use tokio::net::TcpStream;
use tokio::time::sleep;
use tracing::{error, info, warn};
use uuid::Uuid;
use walkdir::WalkDir;
// Common constants for all E2E tests
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
pub const TEST_BUCKET: &str = "e2e-test-bucket";
fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, provider_name: &'static str) -> Config {
let credentials = Credentials::new(access_key, secret_key, None, None, provider_name);
let mut config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(endpoint_url)
.force_path_style(true)
.behavior_version_latest();
if endpoint_url.starts_with("http://") {
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
}
config.build()
}
pub fn workspace_root() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.pop(); // e2e_test
@@ -44,29 +66,125 @@ pub fn workspace_root() -> PathBuf {
path
}
pub fn local_http_client() -> HttpClient {
HttpClient::builder()
.no_proxy()
.build()
.expect("failed to build local reqwest client")
}
/// Resolve the RustFS binary relative to the workspace.
/// Always builds the binary to ensure it's up to date.
pub fn rustfs_binary_path() -> PathBuf {
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
}
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
return PathBuf::from(path);
}
// Always build the binary to ensure it's up to date
info!("Building RustFS binary to ensure it's up to date...");
build_rustfs_binary();
let mut binary_path = workspace_root();
binary_path.push("target");
let profile_dir = if cfg!(debug_assertions) { "debug" } else { "release" };
binary_path.push(profile_dir);
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
let features_match = binary_features_match(&binary_path, requested_features);
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
if source_is_newer {
warn!(
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
binary_path
);
}
info!("Using existing RustFS binary at {:?}", binary_path);
return binary_path;
}
info!("Building RustFS binary to ensure it's up to date...");
build_rustfs_binary(requested_features);
info!("Using RustFS binary at {:?}", binary_path);
binary_path
}
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
return true;
};
let Ok(binary_modified) = binary_meta.modified() else {
return true;
};
let workspace = workspace_root();
let watch_roots = [
workspace.join("Cargo.toml"),
workspace.join("Cargo.lock"),
workspace.join("rustfs"),
workspace.join("crates"),
];
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
}
fn running_inside_e2e_test_binary() -> bool {
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
}
fn requested_rustfs_build_features() -> Option<String> {
std::env::var("RUSTFS_BUILD_FEATURES")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
binary_path.with_extension("features")
}
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
let recorded = stdfs::read_to_string(stamp_path).ok().map(|value| value.trim().to_string());
match requested_features {
Some(features) => recorded.as_deref() == Some(features),
None => recorded.as_deref().is_none_or(str::is_empty),
}
}
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
if path.is_file() {
return std::fs::metadata(path)
.and_then(|meta| meta.modified())
.map(|modified| modified > binary_modified)
.unwrap_or(false);
}
if !path.is_dir() {
return false;
}
WalkDir::new(path)
.into_iter()
.filter_entry(|entry| {
let name = entry.file_name();
name != OsStr::new("target") && name != OsStr::new(".git")
})
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.any(|entry| {
std::fs::metadata(entry.path())
.and_then(|meta| meta.modified())
.map(|modified| modified > binary_modified)
.unwrap_or(false)
})
}
/// Build the RustFS binary using cargo
fn build_rustfs_binary() {
fn build_rustfs_binary(requested_features: Option<&str>) {
let workspace = workspace_root();
info!("Building RustFS binary from workspace: {:?}", workspace);
@@ -81,11 +199,8 @@ fn build_rustfs_binary() {
let mut cmd = Command::new("cargo");
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
// Read features from environment variable for e2e tests
if let Ok(features) = std::env::var("RUSTFS_BUILD_FEATURES")
&& !features.is_empty()
{
cmd.arg("--features").arg(&features);
if let Some(features) = requested_features {
cmd.arg("--features").arg(features);
info!("Building with features: {}", features);
}
@@ -105,6 +220,15 @@ fn build_rustfs_binary() {
panic!("Failed to build RustFS binary. Error: {stderr}");
}
let mut binary_path = workspace;
binary_path.push("target");
binary_path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
}
info!("✅ RustFS binary built successfully");
}
@@ -114,6 +238,17 @@ fn awscurl_binary_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("awscurl"))
}
pub fn awscurl_available() -> bool {
let path = awscurl_binary_path();
if path.components().count() > 1 || path.is_absolute() {
return path.is_file();
}
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(&path).is_file()))
.unwrap_or(false)
}
// Global initialization
static INIT: Once = Once::new();
@@ -183,24 +318,22 @@ impl RustFSTestEnvironment {
/// Kill any existing RustFS processes
pub async fn cleanup_existing_processes(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Cleaning up any existing RustFS processes");
let binary_path = rustfs_binary_path();
let binary_name = binary_path.to_string_lossy();
let output = Command::new("pkill").args(["-f", &binary_name]).output();
info!("Cleaning up any existing RustFS processes for {}", self.address);
if let Ok(output) = output
&& output.status.success()
{
info!("Killed existing RustFS processes: {}", binary_name);
sleep(Duration::from_millis(1000)).await;
for pattern in [&self.address, &self.temp_dir] {
let output = Command::new("pkill").args(["-f", pattern]).output();
if let Ok(output) = output
&& output.status.success()
{
info!("Killed existing RustFS processes matching: {}", pattern);
sleep(Duration::from_millis(250)).await;
}
}
Ok(())
}
/// Start RustFS server with basic configuration
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.cleanup_existing_processes().await?;
fn build_start_args<'a>(&'a self, extra_args: Vec<&'a str>) -> Vec<&'a str> {
let mut args = vec![
"--address",
&self.address,
@@ -210,16 +343,29 @@ impl RustFSTestEnvironment {
&self.secret_key,
];
// Add extra arguments
args.extend(extra_args);
// Add temp directory as the last argument
args.push(&self.temp_dir);
args
}
async fn start_rustfs_server_inner(
&mut self,
extra_args: Vec<&str>,
cleanup_existing: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cleanup_existing {
self.cleanup_existing_processes().await?;
}
let args = self.build_start_args(extra_args);
info!("Starting RustFS server with args: {:?}", args);
let binary_path = rustfs_binary_path();
let process = Command::new(&binary_path).args(&args).spawn()?;
let process = Command::new(&binary_path)
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
.args(&args)
.spawn()?;
self.process = Some(process);
@@ -229,18 +375,40 @@ impl RustFSTestEnvironment {
Ok(())
}
/// Wait for RustFS server to be ready by checking TCP connectivity
/// Start RustFS server with basic configuration
pub async fn start_rustfs_server(&mut self, extra_args: Vec<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.start_rustfs_server_inner(extra_args, true).await
}
/// Start RustFS server without cleaning up other running RustFS processes.
///
/// This is useful for tests that need multiple independent RustFS instances
/// alive at the same time.
pub async fn start_rustfs_server_without_cleanup(
&mut self,
extra_args: Vec<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.start_rustfs_server_inner(extra_args, false).await
}
/// Wait for RustFS server to be ready.
///
/// A listening TCP port is not sufficient here: the process may accept
/// connections before the S3 stack is fully initialized, which causes
/// early requests to fail intermittently. Treat readiness as "S3 API
/// responds successfully" instead.
pub async fn wait_for_server_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Waiting for RustFS server to be ready on {}", self.address);
let client = self.create_s3_client();
for i in 0..30 {
if TcpStream::connect(&self.address).await.is_ok() {
for i in 0..60 {
if TcpStream::connect(&self.address).await.is_ok() && client.list_buckets().send().await.is_ok() {
info!("✅ RustFS server is ready after {} attempts", i + 1);
return Ok(());
}
if i == 29 {
return Err("RustFS server failed to become ready within 30 seconds".into());
if i == 59 {
return Err("RustFS server failed to become ready within 60 seconds".into());
}
sleep(Duration::from_secs(1)).await;
@@ -251,16 +419,7 @@ impl RustFSTestEnvironment {
/// Create an AWS S3 client configured for this RustFS instance
pub fn create_s3_client(&self) -> Client {
let credentials = Credentials::new(&self.access_key, &self.secret_key, None, None, "e2e-test");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&self.url)
.force_path_style(true)
.behavior_version_latest()
.build();
Client::from_conf(config)
Client::from_conf(build_test_s3_config(&self.url, &self.access_key, &self.secret_key, "e2e-test"))
}
/// Create test bucket
@@ -493,6 +652,7 @@ impl RustFSTestClusterEnvironment {
.env("RUSTFS_ACCESS_KEY", &self.access_key)
.env("RUSTFS_SECRET_KEY", &self.secret_key)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
.current_dir(&node.data_dir)
.spawn()?;
@@ -503,7 +663,9 @@ impl RustFSTestClusterEnvironment {
self.wait_for_node_ready(&node.address, i).await?;
}
self.wait_for_service_ready().await?;
for node_idx in 0..self.nodes.len() {
self.wait_for_node_service_ready(node_idx).await?;
}
Ok(())
}
@@ -523,17 +685,17 @@ impl RustFSTestClusterEnvironment {
Err(format!("Node {} failed to become ready", idx).into())
}
/// Wait for the entire cluster's S3-compatible service to be ready (internal helper method).
/// Wait for a specific node's S3-compatible service to be ready (internal helper method).
///
/// Verifies service availability by calling the S3 `list_buckets` API, retries up to 120 times
/// with a 1-second interval between attempts. Fails if the API call remains unsuccessful after all retries.
async fn wait_for_service_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.create_s3_client(0)?;
/// Verifies service availability by calling the S3 `list_buckets` API against the requested node,
/// retries up to 120 times with a 1-second interval between attempts.
async fn wait_for_node_service_ready(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = self.create_s3_client(node_idx)?;
for attempt in 0..120 {
match client.list_buckets().send().await {
Ok(_) => {
info!("Cluster service ready after {} attempts", attempt + 1);
info!("Cluster node {} service ready after {} attempts", node_idx, attempt + 1);
return Ok(());
}
Err(_) => {
@@ -541,7 +703,8 @@ impl RustFSTestClusterEnvironment {
}
}
}
Err("Cluster service failed to become ready".into())
Err(format!("Cluster node {} service failed to become ready", node_idx).into())
}
/// Create an S3 client configured to communicate with a specific cluster node.
@@ -562,15 +725,12 @@ impl RustFSTestClusterEnvironment {
if node_idx >= self.nodes.len() {
return Err("node_idx is invalid".into());
}
let credentials = Credentials::new(&self.access_key, &self.secret_key, None, None, "cluster-test");
let config = Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(&self.nodes[node_idx].url)
.force_path_style(true)
.behavior_version_latest()
.build();
Ok(Client::from_conf(config))
Ok(Client::from_conf(build_test_s3_config(
&self.nodes[node_idx].url,
&self.access_key,
&self.secret_key,
"cluster-test",
)))
}
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
+29 -10
View File
@@ -22,11 +22,13 @@
//! - KMS backend configuration (Local and Vault)
//! - SSE encryption testing utilities
use crate::common::{RustFSTestEnvironment, awscurl_get, awscurl_post, init_logging as common_init_logging};
use crate::common::{
RustFSTestEnvironment, awscurl_available, awscurl_get, awscurl_post, init_logging as common_init_logging, local_http_client,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use serde_json;
use std::process::{Child, Command};
use std::time::Duration;
@@ -51,6 +53,19 @@ pub fn init_logging() {
// Additional KMS-specific logging configuration can be added here if needed
}
pub fn skip_if_kms_admin_tool_unavailable(test_name: &str) -> bool {
if awscurl_available() {
return false;
}
info!("Skipping {} because awscurl is not available in PATH", test_name);
true
}
pub fn sse_customer_key_md5_base64(key: &str) -> String {
BASE64.encode(md5::compute(key).0)
}
// KMS-specific helper functions
/// Configure KMS backend via admin API
pub async fn configure_kms(
@@ -133,10 +148,10 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<
"usage": "EncryptDecrypt",
"status": "Active",
"metadata": HashMap::<String, String>::new(),
"created_at": chrono::Utc::now().to_rfc3339(),
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
"rotated_at": serde_json::Value::Null,
"created_by": "e2e-test",
"encrypted_key_material": key_data.to_vec(),
"encrypted_key_material": BASE64.encode(key_data),
"nonce": Vec::<u8>::new()
});
@@ -155,7 +170,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<(
let test_key = "01234567890123456789012345678901"; // 32-byte key
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_md5 = format!("{:x}", md5::compute(test_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let test_data = b"Hello, KMS SSE-C World!";
let object_key = "test-sse-c-object";
@@ -272,6 +287,10 @@ pub async fn test_kms_key_management(
access_key: &str,
secret_key: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if skip_if_kms_admin_tool_unavailable("test_kms_key_management") {
return Ok(());
}
info!("Testing KMS key management APIs");
// Test CreateKey
@@ -324,8 +343,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
let wrong_key = "98765432109876543210987654321098";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key);
let test_key_md5 = format!("{:x}", md5::compute(test_key));
let wrong_key_md5 = format!("{:x}", md5::compute(wrong_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
let test_data = b"Test data for error scenarios";
let object_key = "test-error-object";
@@ -406,7 +425,7 @@ impl VaultTestEnvironment {
let port_check = TcpStream::connect(VAULT_ADDRESS).await.is_ok();
if port_check {
// Additional check by making a health request
if let Ok(response) = reqwest::get(&format!("{VAULT_URL}/v1/sys/health")).await
if let Ok(response) = local_http_client().get(format!("{VAULT_URL}/v1/sys/health")).send().await
&& response.status().is_success()
{
info!("Vault server is ready after {} seconds", i);
@@ -426,7 +445,7 @@ impl VaultTestEnvironment {
/// Setup Vault transit secrets engine
pub async fn setup_vault_transit(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::new();
let client = local_http_client();
info!("Enabling Vault transit secrets engine");
@@ -687,7 +706,7 @@ pub async fn test_multipart_upload_with_config(
/// Create a standard SSE-C encryption configuration for testing
pub fn create_sse_c_config() -> EncryptionType {
let key = "01234567890123456789012345678901"; // 32-byte key
let key_md5 = format!("{:x}", md5::compute(key));
let key_md5 = sse_customer_key_md5_base64(key);
EncryptionType::SSEC {
key: key.to_string(),
key_md5,
@@ -26,27 +26,20 @@ use serial_test::serial;
use std::collections::{HashMap, VecDeque};
use tracing::info;
fn assert_encryption_metadata(metadata: &HashMap<String, String>, expected_size: usize) {
fn assert_managed_encryption_metadata_hidden(metadata: Option<&HashMap<String, String>>) {
let Some(metadata) = metadata else { return };
for key in [
"x-rustfs-encryption-key",
"x-rustfs-encryption-iv",
"x-rustfs-encryption-context",
"x-rustfs-encryption-original-size",
] {
assert!(metadata.contains_key(key), "expected managed encryption metadata '{key}' to be present");
assert!(
!metadata.get(key).unwrap().is_empty(),
"managed encryption metadata '{key}' should not be empty"
!metadata.contains_key(key),
"managed encryption metadata '{key}' should not be exposed to clients"
);
}
let size_value = metadata
.get("x-rustfs-encryption-original-size")
.expect("managed encryption metadata should include original size");
let parsed_size: usize = size_value
.parse()
.expect("x-rustfs-encryption-original-size should be numeric");
assert_eq!(parsed_size, expected_size, "recorded original size should match uploaded payload length");
}
fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
@@ -142,10 +135,7 @@ async fn test_head_reports_managed_metadata_for_sse_s3() -> Result<(), Box<dyn s
"head_object should advertise SSE-S3"
);
let metadata = head
.metadata()
.expect("head_object should return managed encryption metadata");
assert_encryption_metadata(metadata, payload.len());
assert_managed_encryption_metadata_hidden(head.metadata());
assert_storage_encrypted(std::path::Path::new(&kms_env.base_env.temp_dir), TEST_BUCKET, key, payload);
@@ -210,10 +200,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
&default_key_id,
"source object should maintain the configured KMS key id"
);
let source_metadata = head_source
.metadata()
.expect("source object should include managed encryption metadata");
assert_encryption_metadata(source_metadata, payload.len());
assert_managed_encryption_metadata_hidden(head_source.metadata());
let dest_key = "metadata-sse-kms-object-copy";
let copy_source = format!("{TEST_BUCKET}/{source_key}");
@@ -238,10 +225,7 @@ async fn test_head_reports_managed_metadata_for_sse_kms_and_copy() -> Result<(),
&default_key_id,
"copied object should keep the default KMS key id"
);
let dest_metadata = head_dest
.metadata()
.expect("copied object should include managed encryption metadata");
assert_encryption_metadata(dest_metadata, payload.len());
assert_managed_encryption_metadata_hidden(head_dest.metadata());
let copied_body = s3_client
.get_object()
@@ -358,10 +342,7 @@ async fn test_multipart_upload_writes_encrypted_data() -> Result<(), Box<dyn std
"multipart object should retain bucket default KMS key"
);
assert_encryption_metadata(
head.metadata().expect("multipart head_object should expose managed metadata"),
combined.len(),
);
assert_managed_encryption_metadata_hidden(head.metadata());
// Data returned to clients should decrypt back to original payload
let fetched = s3_client
@@ -19,9 +19,9 @@
//! complex workflows.
use super::common::{
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, test_all_multipart_encryption_types,
test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption, test_sse_kms_encryption,
test_sse_s3_encryption,
EncryptionType, LocalKMSTestEnvironment, MultipartTestConfig, create_sse_c_config, sse_customer_key_md5_base64,
test_all_multipart_encryption_types, test_kms_key_management, test_multipart_upload_with_config, test_sse_c_encryption,
test_sse_kms_encryption, test_sse_s3_encryption,
};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
@@ -149,8 +149,8 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
// Test different SSE-C keys to ensure isolation
let key1 = "01234567890123456789012345678901";
let key2 = "98765432109876543210987654321098";
let key1_md5 = format!("{:x}", md5::compute(key1));
let key2_md5 = format!("{:x}", md5::compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
let config1 = MultipartTestConfig::new(
"isolation-test-key1",
@@ -183,7 +183,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
info!("🔒 Verify key isolation");
let wrong_key = "11111111111111111111111111111111";
let wrong_key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, wrong_key);
let wrong_key_md5 = format!("{:x}", md5::compute(wrong_key));
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
// Try to read file encrypted with key1 using wrong key
let wrong_read_result = s3_client
@@ -21,7 +21,7 @@
//! - Concurrent encryption operations
//! - Security validation tests
use super::common::LocalKMSTestEnvironment;
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine;
@@ -72,7 +72,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
info!("📤 Testing SSE-C with zero-byte file");
let test_key = "01234567890123456789012345678901";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_md5 = format!("{:x}", compute(test_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let object_key_c = "zero-byte-sse-c";
let _put_response_c = s3_client
@@ -166,7 +166,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
info!("📤 Testing SSE-C with single-byte file");
let test_key = "01234567890123456789012345678901";
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
let test_key_md5 = format!("{:x}", compute(test_key));
let test_key_md5 = sse_customer_key_md5_base64(test_key);
let object_key_c = "single-byte-sse-c";
s3_client
@@ -334,7 +334,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
info!("🔍 Testing access to SSE-C object without key");
// First upload a valid SSE-C object
let valid_key_md5 = format!("{:x}", compute(valid_key));
let valid_key_md5 = sse_customer_key_md5_base64(valid_key);
s3_client
.put_object()
.bucket(TEST_BUCKET)
@@ -421,7 +421,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
// SSE-C
let key = format!("testkey{i:026}"); // 32-byte key
let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
let key_md5 = format!("{:x}", compute(&key));
let key_md5 = sse_customer_key_md5_base64(&key);
client
.put_object()
@@ -493,8 +493,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
let key1_b64 = base64::engine::general_purpose::STANDARD.encode(key1);
let key2_b64 = base64::engine::general_purpose::STANDARD.encode(key2);
let key1_md5 = format!("{:x}", compute(key1));
let key2_md5 = format!("{:x}", compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
// Upload same data with different keys
s3_client
+10 -4
View File
@@ -19,7 +19,10 @@
//! - S3 object upload/download with SSE-S3, SSE-KMS, SSE-C encryption
//! - Complete encryption/decryption lifecycle
use super::common::{LocalKMSTestEnvironment, get_kms_status, test_kms_key_management, test_sse_c_encryption};
use super::common::{
LocalKMSTestEnvironment, get_kms_status, skip_if_kms_admin_tool_unavailable, sse_customer_key_md5_base64,
test_kms_key_management, test_sse_c_encryption,
};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tracing::{error, info};
@@ -28,6 +31,9 @@ use tracing::{error, info};
#[serial]
async fn test_local_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_local_kms_end_to_end") {
return Ok(());
}
info!("Starting Local KMS End-to-End Test");
// Create LocalKMS test environment
@@ -140,8 +146,8 @@ async fn test_local_kms_key_isolation() {
let key2 = "98765432109876543210987654321098";
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
let key1_md5 = format!("{:x}", md5::compute(key1));
let key2_md5 = format!("{:x}", md5::compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
let data1 = b"Data encrypted with key 1";
let data2 = b"Data encrypted with key 2";
@@ -562,7 +568,7 @@ async fn test_multipart_upload_with_sse_c(
// SSE-C encryption key
let encryption_key = "01234567890123456789012345678901";
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, encryption_key);
let key_md5 = format!("{:x}", md5::compute(encryption_key));
let key_md5 = sse_customer_key_md5_base64(encryption_key);
// Generate test data
let test_data: Vec<u8> = (0..total_size).map(|i| ((i * 3) % 256) as u8).collect();
+20 -5
View File
@@ -19,14 +19,14 @@
//! multipart upload behaviour.
use crate::common::{TEST_BUCKET, init_logging};
use md5::compute;
use serial_test::serial;
use tokio::time::{Duration, sleep};
use tracing::{error, info};
use super::common::{
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, start_kms, test_all_multipart_encryption_types, test_error_scenarios,
test_kms_key_management, test_sse_c_encryption, test_sse_kms_encryption, test_sse_s3_encryption,
VAULT_KEY_NAME, VaultTestEnvironment, get_kms_status, skip_if_kms_admin_tool_unavailable, sse_customer_key_md5_base64,
start_kms, test_all_multipart_encryption_types, test_error_scenarios, test_kms_key_management, test_sse_c_encryption,
test_sse_kms_encryption, test_sse_s3_encryption,
};
/// Helper that brings up Vault, configures RustFS, and starts the KMS service.
@@ -65,6 +65,9 @@ impl VaultKmsTestContext {
#[serial]
async fn test_vault_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_end_to_end") {
return Ok(());
}
info!("Starting Vault KMS End-to-End Test with default key {}", VAULT_KEY_NAME);
let context = VaultKmsTestContext::new().await?;
@@ -118,6 +121,9 @@ async fn test_vault_kms_end_to_end() -> Result<(), Box<dyn std::error::Error + S
#[serial]
async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_key_isolation") {
return Ok(());
}
info!("Starting Vault KMS SSE-C key isolation test");
let context = VaultKmsTestContext::new().await?;
@@ -133,8 +139,8 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
let key2 = "98765432109876543210987654321098";
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
let key1_md5 = format!("{:x}", compute(key1));
let key2_md5 = format!("{:x}", compute(key2));
let key1_md5 = sse_customer_key_md5_base64(key1);
let key2_md5 = sse_customer_key_md5_base64(key2);
let data1 = b"Vault data encrypted with key 1";
let data2 = b"Vault data encrypted with key 2";
@@ -202,6 +208,9 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
#[serial]
async fn test_vault_kms_large_file() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_large_file") {
return Ok(());
}
info!("Starting Vault KMS large file SSE-S3 test");
let context = VaultKmsTestContext::new().await?;
@@ -264,6 +273,9 @@ async fn test_vault_kms_large_file() -> Result<(), Box<dyn std::error::Error + S
#[serial]
async fn test_vault_kms_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_multipart_upload") {
return Ok(());
}
info!("Starting Vault KMS multipart upload encryption suite");
let context = VaultKmsTestContext::new().await?;
@@ -292,6 +304,9 @@ async fn test_vault_kms_multipart_upload() -> Result<(), Box<dyn std::error::Err
#[serial]
async fn test_vault_kms_key_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_if_kms_admin_tool_unavailable("test_vault_kms_key_operations") {
return Ok(());
}
info!("Starting Vault KMS key operations test (CRUD)");
let context = VaultKmsTestContext::new().await?;
@@ -21,7 +21,7 @@
//! 3. Test the saving and reading of encrypted metadata
//! 4. Test the complete sharded upload encryption process
use super::common::LocalKMSTestEnvironment;
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging};
use serial_test::serial;
use tracing::{debug, info};
@@ -504,7 +504,7 @@ async fn test_multipart_encryption_type(
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
let key = "01234567890123456789012345678901";
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
let key_md5 = format!("{:x}", md5::compute(key));
let key_md5 = sse_customer_key_md5_base64(key);
(Some(key_b64), Some(key_md5))
} else {
(None, None)
+16
View File
@@ -88,3 +88,19 @@ mod checksum_upload_test;
// Group deletion tests
#[cfg(test)]
mod group_delete_test;
// S3 dummy-compat bucket API tests
#[cfg(test)]
mod bucket_logging_test;
// Multipart control API auth regression tests
#[cfg(test)]
mod multipart_auth_test;
// Object lambda end-to-end regression tests
#[cfg(test)]
mod object_lambda_test;
// Replication extension end-to-end regression tests
#[cfg(test)]
mod replication_extension_test;
File diff suppressed because it is too large Load Diff
+985
View File
@@ -0,0 +1,985 @@
// 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 crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::primitives::ByteStream;
use http::header::{CONTENT_TYPE, HOST};
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::{pre_sign_v4, sign_v4};
use s3s::Body;
use serial_test::serial;
use std::collections::HashMap;
use std::error::Error;
use time::OffsetDateTime;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::{Duration, timeout};
#[derive(Debug)]
struct CapturedWebhookRequest {
headers: HashMap<String, String>,
payload: serde_json::Value,
}
struct WebhookResponseSpec {
status_line: String,
body: Vec<u8>,
headers: Vec<(String, String)>,
include_auth_headers: bool,
auth_route_override: Option<String>,
auth_token_override: Option<String>,
}
fn find_header_terminator(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|window| window == b"\r\n\r\n")
}
async fn read_http_request(
stream: &mut tokio::net::TcpStream,
) -> Result<(HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let header_end = loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err("webhook request ended before headers were fully received".into());
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(pos) = find_header_terminator(&buffer) {
break pos;
}
};
let header_bytes = &buffer[..header_end];
let header_text = std::str::from_utf8(header_bytes)?;
let mut lines = header_text.split("\r\n");
let _request_line = lines.next().ok_or("missing request line")?;
let mut headers = HashMap::new();
for line in lines {
if line.is_empty() {
continue;
}
let (name, value) = line.split_once(':').ok_or("invalid header line")?;
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
let content_length = headers
.get("content-length")
.ok_or("missing content-length header")?
.parse::<usize>()?;
let body_offset = header_end + 4;
while buffer.len().saturating_sub(body_offset) < content_length {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Err("webhook request ended before body was fully received".into());
}
buffer.extend_from_slice(&chunk[..read]);
}
Ok((headers, buffer[body_offset..body_offset + content_length].to_vec()))
}
async fn spawn_object_lambda_webhook_server() -> Result<
(
String,
oneshot::Receiver<CapturedWebhookRequest>,
tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "200 OK".to_string(),
body: b"transformed through object lambda".to_vec(),
headers: vec![("content-type".to_string(), "text/plain".to_string())],
include_auth_headers: true,
auth_route_override: None,
auth_token_override: None,
})
.await
}
async fn spawn_object_lambda_webhook_server_with_response(
response_spec: WebhookResponseSpec,
) -> Result<
(
String,
oneshot::Receiver<CapturedWebhookRequest>,
tokio::task::JoinHandle<Result<(), Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let webhook_url = format!("http://{address}/transform");
let (request_tx, request_rx) = oneshot::channel();
let handle = tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await?;
let Ok(Ok((headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await else {
continue;
};
let payload: serde_json::Value = serde_json::from_slice(&body)?;
let output_route = payload["getObjectContext"]["outputRoute"]
.as_str()
.ok_or("missing outputRoute in webhook payload")?
.to_string();
let output_token = payload["getObjectContext"]["outputToken"]
.as_str()
.ok_or("missing outputToken in webhook payload")?
.to_string();
let _ = request_tx.send(CapturedWebhookRequest { headers, payload });
let mut response_head = format!(
"HTTP/1.1 {}\r\ncontent-length: {}\r\nconnection: close\r\n",
response_spec.status_line,
response_spec.body.len()
);
for (name, value) in &response_spec.headers {
response_head.push_str(&format!("{name}: {value}\r\n"));
}
if response_spec.include_auth_headers {
let auth_route = response_spec.auth_route_override.as_deref().unwrap_or(&output_route);
let auth_token = response_spec.auth_token_override.as_deref().unwrap_or(&output_token);
response_head.push_str(&format!("x-amz-request-route: {auth_route}\r\n"));
response_head.push_str(&format!("x-amz-request-token: {auth_token}\r\n"));
}
response_head.push_str("\r\n");
stream.write_all(response_head.as_bytes()).await?;
stream.write_all(&response_spec.body).await?;
stream.shutdown().await?;
return Ok(());
}
});
Ok((webhook_url, request_rx, handle))
}
async fn presigned_get_request(
url: &str,
access_key: &str,
secret_key: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let signed = pre_sign_v4(
http::Request::builder()
.method(http::Method::GET)
.uri(uri)
.header(HOST, authority)
.body(Body::empty())?,
access_key,
secret_key,
"",
"us-east-1",
600,
OffsetDateTime::now_utc(),
);
Ok(local_http_client().get(signed.uri().to_string()).send().await?)
}
async fn signed_request(
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let client = local_http_client();
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
async fn configure_webhook_target(
env: &RustFSTestEnvironment,
target_name: &str,
endpoint: &str,
auth_token: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
configure_webhook_target_with_key_values(
env,
target_name,
vec![
("endpoint", endpoint.to_string()),
("auth_token", auth_token.to_string()),
("queue_dir", format!("{}/notify-queue", env.temp_dir)),
],
)
.await
}
async fn configure_webhook_target_with_key_values(
env: &RustFSTestEnvironment,
target_name: &str,
key_values: Vec<(&str, String)>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let queue_dir = format!("{}/notify-queue", env.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
let mut key_values = key_values
.into_iter()
.map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
.collect::<Vec<_>>();
if !key_values.iter().any(|entry| entry["key"].as_str() == Some("queue_dir")) {
key_values.push(serde_json::json!({ "key": "queue_dir", "value": queue_dir }));
}
let response = send_configure_webhook_target_request(env, target_name, key_values).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("failed to configure object lambda webhook target: {status} {body}").into());
}
Ok(())
}
async fn send_configure_webhook_target_request(
env: &RustFSTestEnvironment,
target_name: &str,
key_values: Vec<serde_json::Value>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let payload = serde_json::json!({ "key_values": key_values });
let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{}", env.url, target_name);
signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(payload.to_string().into_bytes()),
Some("application/json"),
)
.await
}
async fn list_notification_targets(env: &RustFSTestEnvironment) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/target/list", env.url);
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.bytes().await?;
if status != StatusCode::OK {
return Err(format!("failed to list notification targets: {status} {}", String::from_utf8_lossy(body.as_ref())).into());
}
Ok(serde_json::from_slice(&body)?)
}
async fn list_target_arns(env: &RustFSTestEnvironment) -> Result<Vec<String>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/rustfs/admin/v3/target/arns", env.url);
let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.bytes().await?;
if status != StatusCode::OK {
return Err(format!("failed to list target arns: {status} {}", String::from_utf8_lossy(body.as_ref())).into());
}
Ok(serde_json::from_slice(&body)?)
}
async fn wait_for_target_visibility(
env: &RustFSTestEnvironment,
target_name: &str,
) -> Result<(serde_json::Value, Vec<String>), Box<dyn Error + Send + Sync>> {
let mut last_targets = serde_json::Value::Null;
let mut last_arns = Vec::new();
for _ in 0..20 {
last_targets = list_notification_targets(env).await?;
last_arns = list_target_arns(env).await?;
let listed = last_targets["notification_endpoints"]
.as_array()
.into_iter()
.flatten()
.any(|entry| {
entry["account_id"].as_str() == Some(target_name)
&& entry["service"]
.as_str()
.is_some_and(|service| service == "webhook" || service.starts_with("webhook-"))
});
if listed {
return Ok((last_targets, last_arns));
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
Err(format!("target {target_name} did not become visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
}
async fn read_persisted_server_config(env: &RustFSTestEnvironment) -> String {
let path = format!("{}/.rustfs.sys/config/config.json", env.temp_dir);
match tokio::fs::read_to_string(&path).await {
Ok(content) => content,
Err(err) => format!("failed to read persisted config at {path}: {err}"),
}
}
async fn read_listen_notification_event(
response: reqwest::Response,
expected_key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut response = response;
let mut pending = String::new();
loop {
let chunk = timeout(Duration::from_secs(12), response.chunk()).await??;
let Some(chunk) = chunk else {
return Err("listen_notification stream ended before payload".into());
};
if chunk.is_empty() {
continue;
}
pending.push_str(&String::from_utf8(chunk.to_vec())?);
while let Some(newline) = pending.find('\n') {
let line = pending.drain(..=newline).collect::<String>();
let payload = line.trim();
if payload.is_empty() {
continue;
}
let json: serde_json::Value = serde_json::from_str(payload)?;
let Some(records) = json["Records"].as_array() else {
continue;
};
if records.is_empty() {
continue;
}
let has_expected_key = records.iter().any(|record| {
let Some(object_key) = record["s3"]["object"]["key"].as_str() else {
return false;
};
let decoded = urlencoding::decode(object_key)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| object_key.to_string());
decoded == expected_key
});
if has_expected_key {
return Ok(payload.to_string());
}
}
}
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-presigned";
let key = "input.txt";
let object_body = b"hello presigned object lambda";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(object_body))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = presigned_get_request(&lambda_url, &env.access_key, &env.secret_key).await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await?, "transformed through object lambda");
let captured = timeout(Duration::from_secs(10), request_rx).await??;
assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-named-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook-preview";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await?, "transformed through object lambda");
let captured = timeout(Duration::from_secs(10), request_rx).await??;
assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e";
let key = "input.txt";
let object_body = b"hello object lambda";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(object_body))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
let (visible_targets, visible_arns) = wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let persisted_config = read_persisted_server_config(&env).await;
return Err(format!(
"object lambda request failed: {status} {body}; visible_targets={visible_targets}; visible_arns={visible_arns:?}; persisted_config={persisted_config}"
)
.into());
}
assert_eq!(
response.headers().get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
Some("text/plain")
);
assert_eq!(response.text().await?, "transformed through object lambda");
let captured = timeout(Duration::from_secs(10), request_rx).await??;
assert_eq!(captured.headers.get("authorization").map(String::as_str), Some("Bearer secret-token"));
assert_eq!(captured.headers.get("x-rustfs-object-lambda-bucket").map(String::as_str), Some(bucket));
assert_eq!(captured.headers.get("x-rustfs-object-lambda-key").map(String::as_str), Some(key));
assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn));
let expected_request_url = format!("/{bucket}/{key}?lambdaArn={}", urlencoding::encode(lambda_arn));
assert_eq!(captured.payload["userRequest"]["url"].as_str(), Some(expected_request_url.as_str()));
let input_s3_url = captured.payload["getObjectContext"]["inputS3Url"]
.as_str()
.ok_or("missing inputS3Url in object lambda payload")?;
assert!(!input_s3_url.contains("lambdaArn="));
let source_response = local_http_client().get(input_s3_url).send().await?;
assert_eq!(source_response.status(), StatusCode::OK);
assert_eq!(source_response.bytes().await?.as_ref(), object_body);
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "418 I'm a teapot".to_string(),
body: b"lambda upstream rejected".to_vec(),
headers: vec![
("content-type".to_string(), "text/plain".to_string()),
("x-rustfs-debug".to_string(), "passthrough".to_string()),
("x-amz-request-route".to_string(), "should-not-leak".to_string()),
("x-amz-request-token".to_string(), "should-not-leak".to_string()),
],
include_auth_headers: false,
auth_route_override: None,
auth_token_override: None,
})
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-failure";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::IM_A_TEAPOT);
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("text/plain")
);
assert_eq!(
response.headers().get("x-rustfs-debug").and_then(|value| value.to_str().ok()),
Some("passthrough")
);
assert!(response.headers().get("x-amz-request-route").is_none());
assert!(response.headers().get("x-amz-request-token").is_none());
assert_eq!(response.text().await?, "lambda upstream rejected");
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_success_response_without_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "200 OK".to_string(),
body: b"missing auth headers".to_vec(),
headers: vec![("content-type".to_string(), "text/plain".to_string())],
include_auth_headers: false,
auth_route_override: None,
auth_token_override: None,
})
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-missing-auth";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.text().await?;
assert!(body.contains("authorization headers"), "unexpected error body: {body}");
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_headers() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec {
status_line: "200 OK".to_string(),
body: b"mismatched auth headers".to_vec(),
headers: vec![("content-type".to_string(), "text/plain".to_string())],
include_auth_headers: true,
auth_route_override: Some("wrong-route".to_string()),
auth_token_override: Some("wrong-token".to_string()),
})
.await?;
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-mismatched-auth";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.text().await?;
assert!(body.contains("authorization headers"), "unexpected error body: {body}");
webhook_handle.await??;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_unsupported_target_type() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-unsupported-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:mqtt";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
assert!(body.contains("NotImplemented"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("target type is not supported"),
"unexpected error body: {body}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_unconfigured_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-missing-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("target is not configured"),
"unexpected error body: {body}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-disabled-target";
let key = "input.txt";
let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
configure_webhook_target_with_key_values(
&env,
"transformer",
vec![
("endpoint", "http://127.0.0.1:9/transform".to_string()),
("auth_token", "secret-token".to_string()),
("enable", "off".to_string()),
],
)
.await?;
wait_for_target_visibility(&env, "transformer").await?;
let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn));
let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected error body: {body}");
assert!(body.to_ascii_lowercase().contains("target is disabled"), "unexpected error body: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "object-lambda-e2e-invalid-endpoint";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("input.txt")
.body(ByteStream::from_static(b"hello object lambda"))
.send()
.await?;
let response = send_configure_webhook_target_request(
&env,
"transformer",
vec![
serde_json::json!({ "key": "endpoint", "value": "://invalid-endpoint" }),
serde_json::json!({ "key": "auth_token", "value": "secret-token" }),
serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }),
],
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidArgument"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("invalid endpoint url"),
"unexpected error body: {body}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_configure_object_lambda_notify_webhook_rejects_response_header_timeout_key()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = send_configure_webhook_target_request(
&env,
"transformer",
vec![
serde_json::json!({ "key": "endpoint", "value": "http://127.0.0.1:9/transform" }),
serde_json::json!({ "key": "auth_token", "value": "secret-token" }),
serde_json::json!({ "key": "response_header_timeout", "value": "not-a-duration" }),
serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }),
],
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidArgument"), "unexpected error body: {body}");
assert!(
body.to_ascii_lowercase().contains("response_header_timeout"),
"unexpected error body: {body}"
);
assert!(body.to_ascii_lowercase().contains("not allowed"), "unexpected error body: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_listen_notification_emits_after_put_object() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "listen-notification-e2e";
let key = "logs/app.json";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
let listen_url = format!(
"{}/{bucket}?events={}&prefix={}&suffix={}&ping=1",
env.url,
urlencoding::encode("s3:ObjectCreated:Put"),
urlencoding::encode("logs/"),
urlencoding::encode(".json"),
);
let response = signed_request(http::Method::GET, &listen_url, &env.access_key, &env.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let read_task = tokio::spawn(read_listen_notification_event(response, key));
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"listen notification body"))
.send()
.await?;
let payload = timeout(Duration::from_secs(12), read_task).await???;
assert!(!payload.is_empty(), "listen_notification payload should not be empty");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let bucket = "listen-notification-cluster";
let key = "logs/cluster.json";
let node0_client = cluster.create_s3_client(0)?;
let node1_client = cluster.create_s3_client(1)?;
node0_client.create_bucket().bucket(bucket).send().await?;
let listen_url = format!(
"{}/{bucket}?events={}&prefix={}&suffix={}&ping=1",
cluster.nodes[0].url,
urlencoding::encode("s3:ObjectCreated:Put"),
urlencoding::encode("logs/"),
urlencoding::encode(".json"),
);
let response = signed_request(http::Method::GET, &listen_url, &cluster.access_key, &cluster.secret_key, None, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let read_task = tokio::spawn(read_listen_notification_event(response, key));
node1_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"cluster listen notification body"))
.send()
.await?;
let payload = timeout(Duration::from_secs(12), read_task).await???;
assert!(!payload.is_empty(), "listen_notification cluster payload should not be empty");
Ok(())
}
+47 -15
View File
@@ -14,11 +14,13 @@
//! Core FTPS tests
use crate::common::rustfs_binary_path;
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
use anyhow::Result;
use rcgen::generate_simple_self_signed;
use rustls::{ClientConfig, RootCertStore, pki_types::CertificateDer, pki_types::pem::PemObject};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, Error as RustlsError, SignatureScheme};
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::Arc;
@@ -31,6 +33,46 @@ use tracing::info;
const FTPS_PORT: u16 = 9021;
const FTPS_ADDRESS: &str = "127.0.0.1:9021";
#[derive(Debug)]
struct AcceptAnyServerCertVerifier;
impl ServerCertVerifier for AcceptAnyServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Test FTPS: put, ls, mkdir, rmdir, delete operations
pub async fn test_ftps_core_operations() -> Result<()> {
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
@@ -58,7 +100,7 @@ pub async fn test_ftps_core_operations() -> Result<()> {
// Start server manually
info!("Starting FTPS server on {}", FTPS_ADDRESS);
let binary_path = rustfs_binary_path();
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav"));
let mut server_process = Command::new(&binary_path)
.env("RUSTFS_FTPS_ENABLE", "true")
.env("RUSTFS_FTPS_ADDRESS", FTPS_ADDRESS)
@@ -78,19 +120,9 @@ pub async fn test_ftps_core_operations() -> Result<()> {
.install_default()
.map_err(|e| anyhow::anyhow!("Failed to install crypto provider: {:?}", e))?;
// Create a simple rustls config that accepts any certificate for testing
let mut root_store = RootCertStore::empty();
// Add the self-signed certificate to the trust store for e2e
// Note: In a real environment, you'd use proper root certificates
let cert_pem = default_cert.cert.pem();
let cert_der = CertificateDer::pem_reader_iter(&mut Cursor::new(cert_pem))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| anyhow::anyhow!("Failed to parse cert: {}", e))?;
root_store.add_parsable_certificates(cert_der);
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
// Wrap in suppaftp's RustlsConnector
+2 -2
View File
@@ -14,7 +14,7 @@
//! Core WebDAV tests
use crate::common::rustfs_binary_path;
use crate::common::rustfs_binary_path_with_features;
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
use anyhow::Result;
use base64::Engine;
@@ -47,7 +47,7 @@ pub async fn test_webdav_core_operations() -> Result<()> {
// Start server manually
info!("Starting WebDAV server on {}", WEBDAV_ADDRESS);
let binary_path = rustfs_binary_path();
let binary_path = rustfs_binary_path_with_features(Some("ftps,webdav"));
let mut server_process = Command::new(&binary_path)
.env("RUSTFS_WEBDAV_ENABLE", "true")
.env("RUSTFS_WEBDAV_ADDRESS", WEBDAV_ADDRESS)
+48
View File
@@ -17,6 +17,15 @@ use aws_sdk_s3::Client;
use serial_test::serial;
use tracing::{debug, info};
fn skip_without_awscurl() -> bool {
if crate::common::awscurl_available() {
return false;
}
info!("Skipping quota test because awscurl is not available");
true
}
/// Test environment setup for quota tests
pub struct QuotaTestEnv {
pub env: RustFSTestEnvironment,
@@ -233,6 +242,9 @@ mod integration_tests {
#[serial]
async fn test_quota_basic_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
// Create test bucket
@@ -269,6 +281,9 @@ mod integration_tests {
#[serial]
async fn test_quota_update_and_clear() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -302,6 +317,9 @@ mod integration_tests {
#[serial]
async fn test_quota_delete_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -336,6 +354,9 @@ mod integration_tests {
#[serial]
async fn test_quota_usage_tracking() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -372,6 +393,9 @@ mod integration_tests {
#[serial]
async fn test_quota_statistics() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -403,6 +427,9 @@ mod integration_tests {
#[serial]
async fn test_quota_check_api() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -441,6 +468,9 @@ mod integration_tests {
#[serial]
async fn test_quota_multiple_buckets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
// Create two buckets in the same environment
@@ -479,6 +509,9 @@ mod integration_tests {
#[serial]
async fn test_quota_error_handling() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -512,6 +545,9 @@ mod integration_tests {
#[serial]
async fn test_quota_http_endpoints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -575,6 +611,9 @@ mod integration_tests {
#[serial]
async fn test_quota_normal_user_permissions() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -628,6 +667,9 @@ mod integration_tests {
#[serial]
async fn test_quota_copy_operations() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -671,6 +713,9 @@ mod integration_tests {
#[serial]
async fn test_quota_batch_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -727,6 +772,9 @@ mod integration_tests {
#[serial]
async fn test_quota_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
if skip_without_awscurl() {
return Ok(());
}
let env = QuotaTestEnv::new().await?;
env.create_bucket().await?;
@@ -551,6 +551,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn get_live_events(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::GetLiveEventsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::GetLiveEventsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn start_profiling(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::StartProfilingRequest>,
@@ -0,0 +1,802 @@
// 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 crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use http::header::{CONTENT_TYPE, HOST};
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serial_test::serial;
use std::error::Error;
async fn signed_request(
method: http::Method,
url: &str,
access_key: &str,
secret_key: &str,
body: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let mut request = http::Request::builder().method(method.clone()).uri(uri);
request = request.header(HOST, authority);
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default();
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
let client = local_http_client();
let mut request_builder = client.request(reqwest_method, url);
for (name, value) in signed.headers() {
request_builder = request_builder.header(name, value);
}
if let Some(body) = body {
request_builder = request_builder.body(body);
}
Ok(request_builder.send().await?)
}
async fn set_replication_target(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
target_env: &RustFSTestEnvironment,
target_bucket: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let body = serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication"
});
let url = format!(
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
source_env.url,
urlencoding::encode(source_bucket)
);
let response = signed_request(
http::Method::PUT,
&url,
&source_env.access_key,
&source_env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("set remote target failed: {status} {body}").into());
}
let body = response.bytes().await?;
let arn: String = serde_json::from_slice(&body)?;
Ok(arn)
}
async fn send_set_replication_target_request(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
update: bool,
body: serde_json::Value,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!(
"{}/rustfs/admin/v3/set-remote-target?bucket={}",
source_env.url,
urlencoding::encode(source_bucket)
);
if update {
url.push_str("&update=true");
}
signed_request(
http::Method::PUT,
&url,
&source_env.access_key,
&source_env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await
}
async fn put_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>rule-1</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication>
<Status>Enabled</Status>
</DeleteMarkerReplication>
<ExistingObjectReplication>
<Status>Enabled</Status>
</ExistingObjectReplication>
<Destination>
<Bucket>{target_arn}</Bucket>
</Destination>
</Rule>
</ReplicationConfiguration>"#
);
let url = format!("{}/{bucket}?replication", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.into_bytes()),
Some("application/xml"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("put bucket replication failed: {status} {body}").into());
}
Ok(())
}
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
let client = env.create_s3_client();
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
async fn run_replication_check(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!("{}/{bucket}?replication-check", env.url);
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn remove_replication_target(
env: &RustFSTestEnvironment,
bucket: &str,
arn: &str,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let url = format!(
"{}/rustfs/admin/v3/remove-remote-target?bucket={}&arn={}",
env.url,
urlencoding::encode(bucket),
urlencoding::encode(arn)
);
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn remove_replication_target_request(
env: &RustFSTestEnvironment,
bucket: Option<&str>,
arn: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/remove-remote-target", env.url);
let mut separator = '?';
if let Some(bucket) = bucket {
url.push(separator);
separator = '&';
url.push_str("bucket=");
url.push_str(&urlencoding::encode(bucket));
}
if let Some(arn) = arn {
url.push(separator);
url.push_str("arn=");
url.push_str(&urlencoding::encode(arn));
}
signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn list_replication_targets_request(
env: &RustFSTestEnvironment,
bucket: Option<&str>,
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
let mut url = format!("{}/rustfs/admin/v3/list-remote-targets", env.url);
if let Some(bucket) = bucket {
url.push_str("?bucket=");
url.push_str(&urlencoding::encode(bucket));
}
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn build_replication_pair(
enable_target_versioning: bool,
) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment, String), Box<dyn Error + Send + Sync>> {
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-src";
let target_bucket = "replication-check-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
if enable_target_versioning {
enable_bucket_versioning(&target_env, target_bucket).await?;
}
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
Ok((source_env, target_env, source_bucket.to_string()))
}
#[tokio::test]
#[serial]
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (_source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
let response = run_replication_check(&_source_env, &source_bucket).await?;
assert_eq!(response.status(), StatusCode::OK);
assert!(response.text().await?.is_empty());
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_target_without_object_lock() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-lock-src";
let target_bucket = "replication-check-lock-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let response = run_replication_check(&source_env, source_bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("object lock"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-unversioned-src";
let target_bucket = "replication-check-unversioned-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let err = set_replication_target(&source_env, source_bucket, &target_env, target_bucket)
.await
.expect_err("unversioned source bucket should be rejected during remote target setup");
let err = err.to_string();
assert!(err.contains("400 Bad Request"), "unexpected set remote target error: {err}");
assert!(err.contains("InvalidRequest"), "unexpected set remote target error: {err}");
assert!(
err.to_ascii_lowercase().contains("not versioned"),
"unexpected set remote target error: {err}"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-check-source-unversioned";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
let response = run_replication_check(&env, bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("versioning"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_missing_replication_config() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-check-missing-config";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let response = run_replication_check(&env, bucket).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("ReplicationConfigurationNotFoundError"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_replication_check_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = run_replication_check(&env, "replication-check-no-such-bucket").await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_same_bucket_on_same_deployment() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-check-same-target";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let body = serde_json::json!({
"endpoint": env.address,
"credentials": {
"accessKey": env.access_key,
"secretKey": env.secret_key
},
"targetbucket": bucket,
"secure": false,
"type": "replication"
});
let url = format!("{}/rustfs/admin/v3/set-remote-target?bucket={}", env.url, urlencoding::encode(bucket));
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.to_string().into_bytes()),
Some("application/json"),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("IncorrectEndpoint"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_unversioned_target_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-check-src";
let target_bucket = "replication-check-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
let err = set_replication_target(&source_env, source_bucket, &target_env, target_bucket)
.await
.expect_err("unversioned target bucket should be rejected during remote target setup");
assert!(err.to_string().contains("not versioned"), "unexpected set remote target error: {err}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_update_requires_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-update-needs-arn-src";
let target_bucket = "replication-update-needs-arn-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let response = send_set_replication_target_request(
&source_env,
source_bucket,
true,
serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("arn is empty"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_update_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-update-missing-target-src";
let target_bucket = "replication-update-missing-target-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let response = send_set_replication_target_request(
&source_env,
source_bucket,
true,
serde_json::json!({
"endpoint": target_env.address,
"credentials": {
"accessKey": target_env.access_key,
"secretKey": target_env.secret_key
},
"targetbucket": target_bucket,
"secure": false,
"type": "replication",
"arn": "arn:aws:s3:us-east-1:123456789012:replication::missing-target"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("target not found"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_set_remote_target_rejects_invalid_target_url() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let bucket = "replication-invalid-target-url-src";
let source_client = source_env.create_s3_client();
source_client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
let response = send_set_replication_target_request(
&source_env,
bucket,
false,
serde_json::json!({
"endpoint": "://invalid-target-url",
"credentials": {
"accessKey": "replication",
"secretKey": "replication"
},
"targetbucket": "target-bucket",
"secure": false,
"type": "replication"
}),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("invalid target url"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_list_remote_targets_rejects_empty_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = list_replication_targets_request(&env, Some("")).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("bucket is required"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_list_remote_targets_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = list_replication_targets_request(&env, Some("missing-replication-target-bucket")).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
source_env.start_rustfs_server(vec![]).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let bucket = "replication-remove-missing-target";
let target_bucket = "replication-remove-missing-target-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let arn = set_replication_target(&source_env, bucket, &target_env, target_bucket).await?;
let first_remove = remove_replication_target(&source_env, bucket, &arn).await?;
assert_eq!(first_remove.status(), StatusCode::NO_CONTENT);
let response = remove_replication_target(&source_env, bucket, &arn).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("not found"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_missing_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "replication-remove-missing-arn";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
enable_bucket_versioning(&env, bucket).await?;
let response = remove_replication_target_request(&env, Some(bucket), None).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("arn is required"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = remove_replication_target_request(
&env,
Some("missing-replication-remove-bucket"),
Some("arn:aws:s3:us-east-1:123456789012:replication::missing"),
)
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("NoSuchBucket"), "unexpected response: {body}");
Ok(())
}
#[tokio::test]
#[serial]
async fn test_remove_remote_target_rejects_target_used_by_replication() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let (source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
let targets_url = format!(
"{}/rustfs/admin/v3/list-remote-targets?bucket={}",
source_env.url,
urlencoding::encode(&source_bucket)
);
let targets_response = signed_request(
http::Method::GET,
&targets_url,
&source_env.access_key,
&source_env.secret_key,
None,
None,
)
.await?;
assert_eq!(targets_response.status(), StatusCode::OK);
let targets: Vec<serde_json::Value> = targets_response.json().await?;
let arn = targets
.first()
.and_then(|target| target.get("arn"))
.and_then(|arn| arn.as_str())
.ok_or("replication target arn missing")?
.to_string();
let response = remove_replication_target(&source_env, &source_bucket, &arn).await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
assert!(body.to_ascii_lowercase().contains("removal disallowed"), "unexpected response: {body}");
Ok(())
}
@@ -454,12 +454,12 @@ mod tests {
Ok(())
}
/// Test 7: PutObject should return "null" version_id when versioning is Suspended
/// Test 7: PutObject should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_put_object_returns_null_version_id_with_suspended_versioning() {
async fn test_put_object_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: PutObject returns null version_id with versioning suspended");
info!("🧪 TEST: PutObject omits version_id with versioning suspended");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
@@ -489,13 +489,130 @@ mod tests {
info!("📥 PutObject response - version_id: {:?}", output.version_id);
// When suspended, version_id must be "null"
// When suspended, version_id must be omitted
assert_eq!(
output.version_id.as_deref(),
Some("null"),
"❌ FAILED: version_id should be 'null' when versioning is suspended"
output.version_id, None,
"❌ FAILED: version_id should be omitted when versioning is suspended"
);
info!("✅ PASSED: PutObject correctly returns 'null' version_id");
info!("✅ PASSED: PutObject correctly omits version_id");
}
/// Test 8: CopyObject should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_copy_object_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: CopyObject omits version_id with versioning suspended");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-copy-suspended-version-id";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
suspend_versioning(&client, bucket)
.await
.expect("Failed to suspend versioning");
let source_key = "source-file.txt";
let dest_key = "dest-file.txt";
let content = b"Content to copy into suspended bucket";
client
.put_object()
.bucket(bucket)
.key(source_key)
.body(ByteStream::from_static(content))
.send()
.await
.expect("Failed to create source object");
let result = client
.copy_object()
.bucket(bucket)
.key(dest_key)
.copy_source(format!("{}/{}", bucket, source_key))
.send()
.await;
assert!(result.is_ok(), "CopyObject failed: {:?}", result.err());
let output = result.unwrap();
info!("📥 CopyObject response - version_id: {:?}", output.version_id);
assert_eq!(
output.version_id, None,
"❌ FAILED: version_id should be omitted when versioning is suspended"
);
info!("✅ PASSED: CopyObject correctly omits version_id");
}
/// Test 9: CompleteMultipartUpload should omit version_id when versioning is Suspended
#[tokio::test]
#[serial]
async fn test_multipart_upload_omits_version_id_with_suspended_versioning() {
init_logging();
info!("🧪 TEST: CompleteMultipartUpload omits version_id with versioning suspended");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-multipart-suspended-version-id";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
suspend_versioning(&client, bucket)
.await
.expect("Failed to suspend versioning");
let key = "multipart-file.txt";
let content = b"Part 1 content for suspended multipart upload test";
let create_result = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.send()
.await
.expect("Failed to create multipart upload");
let upload_id = create_result.upload_id().expect("No upload_id returned");
let upload_part_result = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(content))
.send()
.await
.expect("Failed to upload part");
let etag = upload_part_result.e_tag().expect("No etag returned").to_string();
let completed_part = CompletedPart::builder().part_number(1).e_tag(etag).build();
let completed_upload = CompletedMultipartUpload::builder().parts(completed_part).build();
let result = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed_upload)
.send()
.await;
assert!(result.is_ok(), "CompleteMultipartUpload failed: {:?}", result.err());
let output = result.unwrap();
info!("📥 CompleteMultipartUpload response - version_id: {:?}", output.version_id);
assert_eq!(
output.version_id, None,
"❌ FAILED: version_id should be omitted when versioning is suspended"
);
info!("✅ PASSED: CompleteMultipartUpload correctly omits version_id");
}
}
+32 -18
View File
@@ -1134,11 +1134,20 @@ pub struct S3ClientError {
}
impl S3ClientError {
pub fn new(value: impl Into<String>) -> Self {
Self::with_metadata(value, None, None, None)
}
pub fn with_metadata(
error: impl Into<String>,
status_code: Option<StatusCode>,
code: Option<String>,
message: Option<String>,
) -> Self {
S3ClientError {
error: value.into(),
status_code: None,
code: None,
message: None,
error: error.into(),
status_code,
code,
message,
}
}
@@ -1154,16 +1163,16 @@ impl S3ClientError {
impl<T: aws_sdk_s3::error::ProvideErrorMetadata> From<T> for S3ClientError {
fn from(value: T) -> Self {
S3ClientError {
error: format!(
"{}: {}",
value.code().map(String::from).unwrap_or("unknown code".into()),
value.message().map(String::from).unwrap_or("missing reason".into()),
),
status_code: None,
code: None,
message: None,
}
let code = value.code().map(String::from);
let message = value.message().map(String::from);
let error = match (code.as_deref(), message.as_deref()) {
(Some(code), Some(message)) => format!("{code}: {message}"),
(Some(code), None) => code.to_string(),
(None, Some(message)) => message.to_string(),
(None, None) => "unknown remote error".to_string(),
};
S3ClientError::with_metadata(error, None, code, message)
}
}
@@ -1207,10 +1216,15 @@ impl TargetClient {
other
);
let message = other.meta().meta();
Err(S3ClientError::new(format!(
"failed to check bucket exists for bucket:{bucket} please check the bucket name and credentials, error:{:?}",
message
)))
Err(S3ClientError::with_metadata(
format!(
"failed to check bucket exists for bucket:{bucket} please check the bucket name and credentials, error:{:?}",
message
),
None,
message.code().map(ToOwned::to_owned),
message.message().map(ToOwned::to_owned),
))
}
},
SdkError::DispatchFailure(e) => Err(S3ClientError::new(format!(
@@ -1337,6 +1337,7 @@ mod tests {
assert_eq!(event.action, IlmAction::TransitionAction);
assert_eq!(event.rule_id, "transition-date");
assert_eq!(event.storage_class, "WARM");
assert_eq!(event.due, Some(transition_date));
}
#[tokio::test]
+108 -5
View File
@@ -25,9 +25,9 @@ use crate::store::ECStore;
use byteorder::{BigEndian, ByteOrder, LittleEndian};
use rustfs_policy::policy::BucketPolicy;
use s3s::dto::{
BucketLifecycleConfiguration, CORSConfiguration, NotificationConfiguration, ObjectLockConfiguration,
PublicAccessBlockConfiguration, ReplicationConfiguration, ServerSideEncryptionConfiguration, Tagging,
VersioningConfiguration,
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, CORSConfiguration, NotificationConfiguration,
ObjectLockConfiguration, PublicAccessBlockConfiguration, ReplicationConfiguration, RequestPaymentConfiguration,
ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
};
use serde::Serializer;
use std::collections::HashMap;
@@ -238,6 +238,10 @@ pub const BUCKET_VERSIONING_CONFIG: &str = "versioning.xml";
pub const BUCKET_REPLICATION_CONFIG: &str = "replication.xml";
pub const BUCKET_TARGETS_FILE: &str = "bucket-targets.json";
pub const BUCKET_CORS_CONFIG: &str = "cors.xml";
pub const BUCKET_LOGGING_CONFIG: &str = "logging.xml";
pub const BUCKET_WEBSITE_CONFIG: &str = "website.xml";
pub const BUCKET_ACCELERATE_CONFIG: &str = "accelerate.xml";
pub const BUCKET_REQUEST_PAYMENT_CONFIG: &str = "request-payment.xml";
pub const BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG: &str = "public-access-block.xml";
pub const BUCKET_ACL_CONFIG: &str = "bucket-acl.json";
@@ -258,6 +262,10 @@ pub struct BucketMetadata {
pub bucket_targets_config_json: Vec<u8>,
pub bucket_targets_config_meta_json: Vec<u8>,
pub cors_config_xml: Vec<u8>,
pub logging_config_xml: Vec<u8>,
pub website_config_xml: Vec<u8>,
pub accelerate_config_xml: Vec<u8>,
pub request_payment_config_xml: Vec<u8>,
pub public_access_block_config_xml: Vec<u8>,
pub bucket_acl_config_json: Vec<u8>,
@@ -273,6 +281,10 @@ pub struct BucketMetadata {
pub bucket_targets_config_updated_at: OffsetDateTime,
pub bucket_targets_config_meta_updated_at: OffsetDateTime,
pub cors_config_updated_at: OffsetDateTime,
pub logging_config_updated_at: OffsetDateTime,
pub website_config_updated_at: OffsetDateTime,
pub accelerate_config_updated_at: OffsetDateTime,
pub request_payment_config_updated_at: OffsetDateTime,
pub public_access_block_config_updated_at: OffsetDateTime,
pub bucket_acl_config_updated_at: OffsetDateTime,
@@ -290,6 +302,10 @@ pub struct BucketMetadata {
pub bucket_target_config: Option<BucketTargets>,
pub bucket_target_config_meta: Option<HashMap<String, String>>,
pub cors_config: Option<CORSConfiguration>,
pub logging_config: Option<BucketLoggingStatus>,
pub website_config: Option<WebsiteConfiguration>,
pub accelerate_config: Option<AccelerateConfiguration>,
pub request_payment_config: Option<RequestPaymentConfiguration>,
pub public_access_block_config: Option<PublicAccessBlockConfiguration>,
pub bucket_acl_config: Option<String>,
}
@@ -312,6 +328,10 @@ impl Default for BucketMetadata {
bucket_targets_config_json: Default::default(),
bucket_targets_config_meta_json: Default::default(),
cors_config_xml: Default::default(),
logging_config_xml: Default::default(),
website_config_xml: Default::default(),
accelerate_config_xml: Default::default(),
request_payment_config_xml: Default::default(),
public_access_block_config_xml: Default::default(),
bucket_acl_config_json: Default::default(),
policy_config_updated_at: OffsetDateTime::UNIX_EPOCH,
@@ -326,6 +346,10 @@ impl Default for BucketMetadata {
bucket_targets_config_updated_at: OffsetDateTime::UNIX_EPOCH,
bucket_targets_config_meta_updated_at: OffsetDateTime::UNIX_EPOCH,
cors_config_updated_at: OffsetDateTime::UNIX_EPOCH,
logging_config_updated_at: OffsetDateTime::UNIX_EPOCH,
website_config_updated_at: OffsetDateTime::UNIX_EPOCH,
accelerate_config_updated_at: OffsetDateTime::UNIX_EPOCH,
request_payment_config_updated_at: OffsetDateTime::UNIX_EPOCH,
public_access_block_config_updated_at: OffsetDateTime::UNIX_EPOCH,
bucket_acl_config_updated_at: OffsetDateTime::UNIX_EPOCH,
new_field_updated_at: OffsetDateTime::UNIX_EPOCH,
@@ -341,6 +365,10 @@ impl Default for BucketMetadata {
bucket_target_config: Default::default(),
bucket_target_config_meta: Default::default(),
cors_config: Default::default(),
logging_config: Default::default(),
website_config: Default::default(),
accelerate_config: Default::default(),
request_payment_config: Default::default(),
public_access_block_config: Default::default(),
bucket_acl_config: Default::default(),
}
@@ -411,11 +439,19 @@ impl BucketMetadata {
"BucketTargetsConfigUpdatedAt" => self.bucket_targets_config_updated_at = read_msgp_time_value(rd)?,
"BucketTargetsConfigMetaUpdatedAt" => self.bucket_targets_config_meta_updated_at = read_msgp_time_value(rd)?,
"CorsConfigXML" | "CorsConfigXml" => self.cors_config_xml = read_msgp_bin(rd)?,
"LoggingConfigXML" | "LoggingConfigXml" => self.logging_config_xml = read_msgp_bin(rd)?,
"WebsiteConfigXML" | "WebsiteConfigXml" => self.website_config_xml = read_msgp_bin(rd)?,
"AccelerateConfigXML" | "AccelerateConfigXml" => self.accelerate_config_xml = read_msgp_bin(rd)?,
"RequestPaymentConfigXML" | "RequestPaymentConfigXml" => self.request_payment_config_xml = read_msgp_bin(rd)?,
"PublicAccessBlockConfigXML" | "PublicAccessBlockConfigXml" => {
self.public_access_block_config_xml = read_msgp_bin(rd)?
}
"BucketAclConfigJSON" | "BucketAclConfigJson" => self.bucket_acl_config_json = read_msgp_bin(rd)?,
"CorsConfigUpdatedAt" => self.cors_config_updated_at = read_msgp_time_value(rd)?,
"LoggingConfigUpdatedAt" => self.logging_config_updated_at = read_msgp_time_value(rd)?,
"WebsiteConfigUpdatedAt" => self.website_config_updated_at = read_msgp_time_value(rd)?,
"AccelerateConfigUpdatedAt" => self.accelerate_config_updated_at = read_msgp_time_value(rd)?,
"RequestPaymentConfigUpdatedAt" => self.request_payment_config_updated_at = read_msgp_time_value(rd)?,
"PublicAccessBlockConfigUpdatedAt" => self.public_access_block_config_updated_at = read_msgp_time_value(rd)?,
"BucketAclConfigUpdatedAt" => self.bucket_acl_config_updated_at = read_msgp_time_value(rd)?,
other => {
@@ -430,8 +466,8 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Map size: MinIO fields (25) + RustFS extensions (6)
let map_len: u32 = 31;
// Map size: MinIO fields (25) + RustFS extensions (14)
let map_len: u32 = 39;
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -481,10 +517,22 @@ impl BucketMetadata {
// RustFS extensions
write_bin_field(wr, "CorsConfigXML", &self.cors_config_xml)?;
write_bin_field(wr, "LoggingConfigXML", &self.logging_config_xml)?;
write_bin_field(wr, "WebsiteConfigXML", &self.website_config_xml)?;
write_bin_field(wr, "AccelerateConfigXML", &self.accelerate_config_xml)?;
write_bin_field(wr, "RequestPaymentConfigXML", &self.request_payment_config_xml)?;
write_bin_field(wr, "PublicAccessBlockConfigXML", &self.public_access_block_config_xml)?;
write_bin_field(wr, "BucketAclConfigJSON", &self.bucket_acl_config_json)?;
rmp::encode::write_str(wr, "CorsConfigUpdatedAt")?;
write_msgp_time(wr, self.cors_config_updated_at)?;
rmp::encode::write_str(wr, "LoggingConfigUpdatedAt")?;
write_msgp_time(wr, self.logging_config_updated_at)?;
rmp::encode::write_str(wr, "WebsiteConfigUpdatedAt")?;
write_msgp_time(wr, self.website_config_updated_at)?;
rmp::encode::write_str(wr, "AccelerateConfigUpdatedAt")?;
write_msgp_time(wr, self.accelerate_config_updated_at)?;
rmp::encode::write_str(wr, "RequestPaymentConfigUpdatedAt")?;
write_msgp_time(wr, self.request_payment_config_updated_at)?;
rmp::encode::write_str(wr, "PublicAccessBlockConfigUpdatedAt")?;
write_msgp_time(wr, self.public_access_block_config_updated_at)?;
rmp::encode::write_str(wr, "BucketAclConfigUpdatedAt")?;
@@ -569,6 +617,18 @@ impl BucketMetadata {
if self.public_access_block_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.public_access_block_config_updated_at = self.created
}
if self.logging_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.logging_config_updated_at = self.created
}
if self.website_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.website_config_updated_at = self.created
}
if self.accelerate_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.accelerate_config_updated_at = self.created
}
if self.request_payment_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.request_payment_config_updated_at = self.created
}
if self.bucket_acl_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.bucket_acl_config_updated_at = self.created
}
@@ -625,6 +685,22 @@ impl BucketMetadata {
self.cors_config_xml = data;
self.cors_config_updated_at = updated;
}
BUCKET_LOGGING_CONFIG => {
self.logging_config_xml = data;
self.logging_config_updated_at = updated;
}
BUCKET_WEBSITE_CONFIG => {
self.website_config_xml = data;
self.website_config_updated_at = updated;
}
BUCKET_ACCELERATE_CONFIG => {
self.accelerate_config_xml = data;
self.accelerate_config_updated_at = updated;
}
BUCKET_REQUEST_PAYMENT_CONFIG => {
self.request_payment_config_xml = data;
self.request_payment_config_updated_at = updated;
}
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG => {
self.public_access_block_config_xml = data;
self.public_access_block_config_updated_at = updated;
@@ -741,6 +817,33 @@ impl BucketMetadata {
{
tracing::warn!(bucket = %self.name, config = "cors", error = %e, "parse_all_configs: failed to parse");
}
if !self.logging_config_xml.is_empty()
&& let Err(e) = deserialize::<BucketLoggingStatus>(&self.logging_config_xml).map(|c| self.logging_config = Some(c))
{
tracing::warn!(bucket = %self.name, config = "logging", error = %e, "parse_all_configs: failed to parse");
}
if !self.website_config_xml.is_empty()
&& let Err(e) = deserialize::<WebsiteConfiguration>(&self.website_config_xml).map(|c| self.website_config = Some(c))
{
tracing::warn!(bucket = %self.name, config = "website", error = %e, "parse_all_configs: failed to parse");
}
if !self.accelerate_config_xml.is_empty()
&& let Err(e) =
deserialize::<AccelerateConfiguration>(&self.accelerate_config_xml).map(|c| self.accelerate_config = Some(c))
{
tracing::warn!(bucket = %self.name, config = "accelerate", error = %e, "parse_all_configs: failed to parse");
}
if !self.request_payment_config_xml.is_empty()
&& let Err(e) = deserialize::<RequestPaymentConfiguration>(&self.request_payment_config_xml)
.map(|c| self.request_payment_config = Some(c))
{
tracing::warn!(
bucket = %self.name,
config = "request_payment",
error = %e,
"parse_all_configs: failed to parse"
);
}
if !self.public_access_block_config_xml.is_empty()
&& let Err(e) = deserialize::<PublicAccessBlockConfiguration>(&self.public_access_block_config_xml)
.map(|c| self.public_access_block_config = Some(c))
+71 -2
View File
@@ -28,8 +28,9 @@ use rustfs_common::heal_channel::HealOpts;
use rustfs_policy::policy::BucketPolicy;
use s3s::dto::ReplicationConfiguration;
use s3s::dto::{
BucketLifecycleConfiguration, CORSConfiguration, NotificationConfiguration, ObjectLockConfiguration,
PublicAccessBlockConfiguration, ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration,
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, CORSConfiguration, NotificationConfiguration,
ObjectLockConfiguration, PublicAccessBlockConfiguration, RequestPaymentConfiguration, ServerSideEncryptionConfiguration,
Tagging, VersioningConfiguration, WebsiteConfiguration,
};
use std::collections::HashSet;
use std::sync::OnceLock;
@@ -193,6 +194,34 @@ pub async fn get_versioning_config(bucket: &str) -> Result<(VersioningConfigurat
bucket_meta_sys.get_versioning_config(bucket).await
}
pub async fn get_website_config(bucket: &str) -> Result<(WebsiteConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_website_config(bucket).await
}
pub async fn get_logging_config(bucket: &str) -> Result<(BucketLoggingStatus, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_logging_config(bucket).await
}
pub async fn get_accelerate_config(bucket: &str) -> Result<(AccelerateConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_accelerate_config(bucket).await
}
pub async fn get_request_payment_config(bucket: &str) -> Result<(RequestPaymentConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_request_payment_config(bucket).await
}
pub async fn get_config_from_disk(bucket: &str) -> Result<BucketMetadata> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -587,6 +616,46 @@ impl BucketMetadataSys {
}
}
pub async fn get_website_config(&self, bucket: &str) -> Result<(WebsiteConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.website_config {
Ok((config.clone(), bm.website_config_updated_at))
} else {
Err(Error::ConfigNotFound)
}
}
pub async fn get_logging_config(&self, bucket: &str) -> Result<(BucketLoggingStatus, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.logging_config {
Ok((config.clone(), bm.logging_config_updated_at))
} else {
Err(Error::ConfigNotFound)
}
}
pub async fn get_accelerate_config(&self, bucket: &str) -> Result<(AccelerateConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.accelerate_config {
Ok((config.clone(), bm.accelerate_config_updated_at))
} else {
Err(Error::ConfigNotFound)
}
}
pub async fn get_request_payment_config(&self, bucket: &str) -> Result<(RequestPaymentConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.request_payment_config {
Ok((config.clone(), bm.request_payment_config_updated_at))
} else {
Err(Error::ConfigNotFound)
}
}
pub async fn created_at(&self, bucket: &str) -> Result<OffsetDateTime> {
let bm = match self.get_config(bucket).await {
Ok((bm, _)) => bm.created,
@@ -21,7 +21,7 @@ use crate::bucket::replication::replicate_delete;
use crate::bucket::replication::replicate_object;
use crate::bucket::replication::replication_resyncer::{
BucketReplicationResyncStatus, DeletedObjectReplicationInfo, REPLICATION_DIR, RESYNC_FILE_NAME, ReplicationConfig,
ReplicationResyncer, decode_resync_file, get_heal_replicate_object_info,
ReplicationResyncer, TargetReplicationResyncStatus, decode_resync_file, get_heal_replicate_object_info, save_resync_status,
};
use crate::bucket::replication::replication_state::ReplicationStats;
use crate::config::com::read_config;
@@ -763,6 +763,63 @@ impl<S: StorageAPI> ReplicationPool<S> {
Ok(())
}
pub async fn get_bucket_resync_status(&self, bucket: &str) -> Result<BucketReplicationResyncStatus, EcstoreError> {
if let Some(status) = self.resyncer.status_map.read().await.get(bucket).cloned() {
return Ok(status);
}
let status = load_bucket_resync_metadata(bucket, self.storage.clone()).await?;
self.resyncer
.status_map
.write()
.await
.insert(bucket.to_string(), status.clone());
Ok(status)
}
pub async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
let now = OffsetDateTime::now_utc();
let bucket_status = {
let mut status_map = self.resyncer.status_map.write().await;
let bucket_status = status_map.entry(opts.bucket.clone()).or_insert_with(|| {
let mut status = BucketReplicationResyncStatus::new();
status.id = 0;
status
});
bucket_status.last_update = Some(now);
bucket_status.targets_map.insert(
opts.arn.clone(),
TargetReplicationResyncStatus {
start_time: Some(now),
last_update: Some(now),
resync_id: opts.resync_id.clone(),
resync_before_date: opts.resync_before,
resync_status: ResyncStatusType::ResyncPending,
failed_size: 0,
failed_count: 0,
replicated_size: 0,
replicated_count: 0,
bucket: opts.bucket.clone(),
object: String::new(),
error: None,
},
);
bucket_status.clone()
};
save_resync_status(&opts.bucket, &bucket_status, self.storage.clone()).await?;
let resyncer = self.resyncer.clone();
let storage = self.storage.clone();
tokio::spawn(async move {
resyncer.resync_bucket(CancellationToken::new(), storage, false, opts).await;
});
Ok(())
}
/// Start the resync routine that runs in a loop
async fn start_resync_routine(self: Arc<Self>, buckets: Vec<String>, cancellation_token: CancellationToken) {
// Run the replication resync in a loop
@@ -891,6 +948,8 @@ pub trait ReplicationPoolTrait: std::fmt::Debug {
async fn queue_replica_task(&self, ri: ReplicateObjectInfo);
async fn queue_replica_delete_task(&self, ri: DeletedObjectReplicationInfo);
async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize);
async fn get_bucket_resync_status(&self, bucket: &str) -> Result<BucketReplicationResyncStatus, EcstoreError>;
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError>;
async fn init_resync(
self: Arc<Self>,
cancellation_token: CancellationToken,
@@ -913,6 +972,14 @@ impl<S: StorageAPI> ReplicationPoolTrait for ReplicationPool<S> {
self.resize(priority, max_workers, max_l_workers).await;
}
async fn get_bucket_resync_status(&self, bucket: &str) -> Result<BucketReplicationResyncStatus, EcstoreError> {
self.get_bucket_resync_status(bucket).await
}
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
self.start_bucket_resync(opts).await
}
async fn init_resync(
self: Arc<Self>,
cancellation_token: CancellationToken,
@@ -901,7 +901,11 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
}
}
async fn save_resync_status<S: StorageAPI>(bucket: &str, status: &BucketReplicationResyncStatus, api: Arc<S>) -> Result<()> {
pub(crate) async fn save_resync_status<S: StorageAPI>(
bucket: &str,
status: &BucketReplicationResyncStatus,
api: Arc<S>,
) -> Result<()> {
let data = encode_resync_file(status)?;
let config_file = path_join_buf(&[BUCKET_META_PREFIX, bucket, REPLICATION_DIR, RESYNC_FILE_NAME]);
+48 -1
View File
@@ -21,6 +21,7 @@ use crate::store::ECStore;
use crate::store_api::ObjectInfo;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::Ordering;
use tokio::sync::RwLock;
use tracing::warn;
@@ -82,4 +83,50 @@ pub struct EventArgs {
impl EventArgs {}
pub fn send_event(args: EventArgs) {}
type EventDispatchHook = Arc<dyn Fn(EventArgs) + Send + Sync + 'static>;
static EVENT_DISPATCH_HOOK: OnceLock<EventDispatchHook> = OnceLock::new();
pub fn register_event_dispatch_hook<F>(hook: F) -> bool
where
F: Fn(EventArgs) + Send + Sync + 'static,
{
EVENT_DISPATCH_HOOK.set(Arc::new(hook)).is_ok()
}
pub fn send_event(args: EventArgs) {
if let Some(hook) = EVENT_DISPATCH_HOOK.get() {
hook(args);
return;
}
warn!(
event_name = args.event_name,
bucket = args.bucket_name,
"event send() dropped because no event dispatch hook is registered"
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static DISPATCH_COUNT: AtomicUsize = AtomicUsize::new(0);
#[test]
fn send_event_dispatches_to_registered_hook() {
let _ = register_event_dispatch_hook(|_args| {
DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
});
let before = DISPATCH_COUNT.load(Ordering::Relaxed);
send_event(EventArgs {
event_name: "s3:ObjectCreated:Put".to_string(),
bucket_name: "demo".to_string(),
..Default::default()
});
assert_eq!(DISPATCH_COUNT.load(Ordering::Relaxed), before + 1);
}
}
+29 -3
View File
@@ -29,9 +29,9 @@ use rustfs_madmin::{
use rustfs_protos::evict_failed_connection;
use rustfs_protos::proto_gen::node_service::{
DeleteBucketMetadataRequest, DeletePolicyRequest, DeleteServiceAccountRequest, DeleteUserRequest, GetCpusRequest,
GetMemInfoRequest, GetMetricsRequest, GetNetInfoRequest, GetOsInfoRequest, GetPartitionsRequest, GetProcInfoRequest,
GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest, LoadBucketMetadataRequest, LoadGroupRequest,
LoadPolicyMappingRequest, LoadPolicyRequest, LoadRebalanceMetaRequest, LoadServiceAccountRequest,
GetLiveEventsRequest, GetMemInfoRequest, GetMetricsRequest, GetNetInfoRequest, GetOsInfoRequest, GetPartitionsRequest,
GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest, LoadBucketMetadataRequest,
LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest, LoadRebalanceMetaRequest, LoadServiceAccountRequest,
LoadTransitionTierConfigRequest, LoadUserRequest, LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest,
ReloadSiteReplicationConfigRequest, ServerInfoRequest, SignalServiceRequest, StartProfilingRequest, StopRebalanceRequest,
node_service_client::NodeServiceClient,
@@ -48,6 +48,13 @@ pub const PEER_RESTSIGNAL: &str = "signal";
pub const PEER_RESTSUB_SYS: &str = "sub-sys";
pub const PEER_RESTDRY_RUN: &str = "dry-run";
#[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch {
pub events: Vec<u8>,
pub next_sequence: u64,
pub truncated: bool,
}
#[derive(Clone, Debug)]
pub struct PeerRestClient {
pub host: XHost,
@@ -333,6 +340,25 @@ impl PeerRestClient {
Ok(realtime_metrics)
}
pub async fn get_live_events(&self, after_sequence: u64, limit: u32) -> Result<PeerLiveEventsBatch> {
let mut client = self.get_client().await?;
let request = Request::new(GetLiveEventsRequest { after_sequence, limit });
let response = client.get_live_events(request).await?.into_inner();
if !response.success {
if let Some(msg) = response.error_info {
return Err(Error::other(msg));
}
return Err(Error::other(""));
}
Ok(PeerLiveEventsBatch {
events: response.events.to_vec(),
next_sequence: response.next_sequence,
truncated: response.truncated,
})
}
pub async fn get_proc_info(&self) -> Result<ProcInfo> {
let mut client = self.get_client().await?;
let request = Request::new(GetProcInfoRequest {});
+5
View File
@@ -701,6 +701,11 @@ impl ObjectIO for SetDisks {
}
let mut user_defined = opts.user_defined.clone();
if let Some(eval_metadata) = &opts.eval_metadata {
for (key, value) in eval_metadata {
user_defined.insert(key.clone(), value.clone());
}
}
let sc_parity_drives = {
if let Some(sc) = GLOBAL_STORAGE_CLASS.get() {
+43 -7
View File
@@ -14,7 +14,8 @@
#![allow(clippy::map_entry)]
use crate::bucket::lifecycle::bucket_lifecycle_ops::init_background_expiry;
use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
use crate::bucket::lifecycle::bucket_lifecycle_ops::{enqueue_transition_immediate, init_background_expiry};
use crate::bucket::metadata_sys::{self, set_bucket_metadata};
use crate::bucket::utils::check_abort_multipart_args;
use crate::bucket::utils::check_complete_multipart_args;
@@ -27,7 +28,7 @@ use crate::bucket::utils::check_new_multipart_args;
use crate::bucket::utils::check_object_args;
use crate::bucket::utils::check_put_object_args;
use crate::bucket::utils::check_put_object_part_args;
use crate::bucket::utils::{check_valid_bucket_name, check_valid_bucket_name_strict};
use crate::bucket::utils::{check_valid_bucket_name, check_valid_bucket_name_strict, is_meta_bucketname};
use crate::config::GLOBAL_STORAGE_CLASS;
use crate::config::storageclass;
use crate::disk::endpoint::{Endpoint, EndpointType};
@@ -129,6 +130,22 @@ async fn has_xlmeta_files(path: &std::path::Path) -> bool {
false
}
async fn enqueue_transition_after_write(result: Result<ObjectInfo>, src: LcEventSrc) -> Result<ObjectInfo> {
match result {
Ok(oi) => {
if should_enqueue_transition_immediately(&oi) {
enqueue_transition_immediate(&oi, src).await;
}
Ok(oi)
}
Err(err) => Err(err),
}
}
fn should_enqueue_transition_immediately(oi: &ObjectInfo) -> bool {
!is_meta_bucketname(&oi.bucket)
}
const MAX_UPLOADS_LIST: usize = 10000;
mod bucket;
@@ -243,7 +260,7 @@ impl ObjectIO for ECStore {
}
#[instrument(level = "debug", skip(self, data))]
async fn put_object(&self, bucket: &str, object: &str, data: &mut PutObjReader, opts: &ObjectOptions) -> Result<ObjectInfo> {
self.handle_put_object(bucket, object, data, opts).await
enqueue_transition_after_write(self.handle_put_object(bucket, object, data, opts).await, LcEventSrc::S3PutObject).await
}
}
@@ -301,8 +318,12 @@ impl ObjectOperations for ECStore {
src_opts: &ObjectOptions,
dst_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
self.handle_copy_object(src_bucket, src_object, dst_bucket, dst_object, src_info, src_opts, dst_opts)
.await
enqueue_transition_after_write(
self.handle_copy_object(src_bucket, src_object, dst_bucket, dst_object, src_info, src_opts, dst_opts)
.await,
LcEventSrc::S3CopyObject,
)
.await
}
#[instrument(skip(self))]
@@ -520,8 +541,12 @@ impl MultipartOperations for ECStore {
uploaded_parts: Vec<CompletePart>,
opts: &ObjectOptions,
) -> Result<ObjectInfo> {
self.handle_complete_multipart_upload(bucket, object, upload_id, uploaded_parts, opts)
.await
enqueue_transition_after_write(
self.handle_complete_multipart_upload(bucket, object, upload_id, uploaded_parts, opts)
.await,
LcEventSrc::S3CompleteMultipartUpload,
)
.await
}
}
@@ -709,6 +734,17 @@ mod tests {
assert!(disks.is_empty() || !disks.is_empty());
}
#[test]
fn test_should_not_enqueue_transition_for_internal_metadata_bucket() {
let oi = ObjectInfo {
bucket: RUSTFS_META_BUCKET.to_string(),
name: format!("{BUCKET_META_PREFIX}/bucket/.metadata.bin"),
..Default::default()
};
assert!(!should_enqueue_transition_immediately(&oi));
}
// Test that we can create the basic structures without global state
#[test]
fn test_pool_available_space_creation() {
+38
View File
@@ -57,7 +57,9 @@ struct StoredMasterKey {
status: KeyStatus,
description: Option<String>,
metadata: HashMap<String, String>,
#[serde(with = "crate::time_serde::zoned")]
created_at: Zoned,
#[serde(with = "crate::time_serde::option_zoned")]
rotated_at: Option<Zoned>,
created_by: Option<String>,
/// Encrypted key material (32 bytes encoded in base64 for AES-256)
@@ -840,6 +842,7 @@ impl KmsBackend for LocalKmsBackend {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
async fn create_test_client() -> (LocalKmsClient, TempDir) {
@@ -943,4 +946,39 @@ mod tests {
// Note: Direct decryption of encrypt() results is not implemented in this simple version
// In a real implementation, encrypt() would create a different envelope format
}
#[tokio::test]
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = LocalConfig {
key_dir: temp_dir.path().to_path_buf(),
master_key: None,
file_permissions: Some(0o600),
};
let client = LocalKmsClient::new(config).await.expect("Failed to create client");
let stored_key = serde_json::json!({
"key_id": "legacy-key",
"version": 1u32,
"algorithm": "AES_256",
"usage": "EncryptDecrypt",
"status": "Active",
"description": serde_json::Value::Null,
"metadata": HashMap::<String, String>::new(),
"created_at": "2024-01-01T00:00:00+00:00",
"rotated_at": serde_json::Value::Null,
"created_by": "legacy-test",
"encrypted_key_material": BASE64.encode([7u8; 32]),
"nonce": Vec::<u8>::new()
});
let key_path = client.master_key_path("legacy-key");
fs::write(&key_path, serde_json::to_vec_pretty(&stored_key).expect("serialize test key"))
.await
.expect("write legacy key");
let key_info = client.load_master_key("legacy-key").await.expect("legacy key should load");
assert_eq!(key_info.key_id, "legacy-key");
assert_eq!(key_info.created_at.time_zone().iana_name(), Some("UTC"));
}
}
+18
View File
@@ -40,6 +40,7 @@ pub struct DataKeyEnvelope {
pub encrypted_key: Vec<u8>,
pub nonce: Vec<u8>,
pub encryption_context: HashMap<String, String>,
#[serde(with = "crate::time_serde::zoned")]
pub created_at: Zoned,
}
@@ -311,4 +312,21 @@ mod tests {
assert_eq!(deserialized.key_id, "test-key-id");
assert_eq!(deserialized.master_key_id, "master-key-id");
}
#[tokio::test]
async fn test_data_key_envelope_accepts_legacy_rfc3339_timestamp() {
let envelope_json = r#"{
"key_id": "test-key-id",
"master_key_id": "master-key-id",
"key_spec": "AES_256",
"encrypted_key": [1, 2, 3, 4],
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"encryption_context": {"bucket": "test-bucket"},
"created_at": "2024-01-01T00:00:00+00:00"
}"#;
let deserialized: DataKeyEnvelope = serde_json::from_str(envelope_json).expect("Should deserialize legacy format");
assert_eq!(deserialized.key_id, "test-key-id");
assert_eq!(deserialized.master_key_id, "master-key-id");
}
}
+1
View File
@@ -65,6 +65,7 @@ mod error;
pub mod manager;
pub mod service;
pub mod service_manager;
mod time_serde;
pub mod types;
// Re-export public API
+13 -2
View File
@@ -49,6 +49,8 @@ pub struct ObjectEncryptionService {
kms_manager: KmsManager,
}
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
/// Result of object encryption
#[derive(Debug, Clone)]
pub struct EncryptionResult {
@@ -604,12 +606,13 @@ impl ObjectEncryptionService {
headers.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
} else if metadata.algorithm == "AES256" {
headers.insert("x-amz-server-side-encryption".to_string(), "AES256".to_string());
// For SSE-S3, we still need to store the key ID for internal use
headers.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), metadata.key_id.clone());
} else {
headers.insert("x-amz-server-side-encryption".to_string(), "aws:kms".to_string());
headers.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), metadata.key_id.clone());
}
if metadata.key_id != "sse-c" {
headers.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), metadata.key_id.clone());
}
// Internal headers for decryption
headers.insert(
@@ -653,8 +656,14 @@ impl ObjectEncryptionService {
let key_id = if algorithm == "AES256" && headers.contains_key("x-amz-server-side-encryption-customer-algorithm") {
"sse-c".to_string()
} else if let Some(key_id) = headers.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) {
key_id.clone()
} else if let Some(kms_key_id) = headers.get("x-amz-server-side-encryption-aws-kms-key-id") {
kms_key_id.clone()
} else if algorithm == "AES256" {
self.get_default_key_id()
.cloned()
.ok_or_else(|| KmsError::validation_error("Missing key ID"))?
} else {
return Err(KmsError::validation_error("Missing key ID"));
};
@@ -821,6 +830,8 @@ mod tests {
let headers = service.metadata_to_headers(&metadata);
assert!(headers.contains_key("x-amz-server-side-encryption"));
assert!(headers.contains_key("x-rustfs-encryption-iv"));
assert!(headers.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER));
assert!(!headers.contains_key("x-amz-server-side-encryption-aws-kms-key-id"));
// Convert back to metadata
let parsed_metadata = service.headers_to_metadata(&headers).expect("Failed to parse headers");
+73
View File
@@ -0,0 +1,73 @@
use jiff::{Timestamp, Zoned, tz::TimeZone};
use serde::{Deserialize, Deserializer, Serializer};
pub(crate) mod zoned {
use super::*;
pub(crate) fn serialize<S>(value: &Zoned, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_string())
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Zoned, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
parse_zoned_compat(&value).map_err(serde::de::Error::custom)
}
}
pub(crate) mod option_zoned {
use super::*;
pub(crate) fn serialize<S>(value: &Option<Zoned>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_some(&value.to_string()),
None => serializer.serialize_none(),
}
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Zoned>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|value| parse_zoned_compat(&value).map_err(serde::de::Error::custom))
.transpose()
}
}
fn parse_zoned_compat(value: &str) -> Result<Zoned, String> {
if let Ok(zoned) = value.parse::<Zoned>() {
return Ok(zoned);
}
let timestamp = value
.parse::<Timestamp>()
.map_err(|err| format!("failed to parse legacy timestamp '{value}': {err}"))?;
Ok(timestamp.to_zoned(TimeZone::UTC))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_zoned_compat_accepts_current_zoned_format() {
let zoned = parse_zoned_compat("2024-01-01T00:00:00+00:00[UTC]").expect("current format should parse");
assert_eq!(zoned.time_zone().iana_name(), Some("UTC"));
}
#[test]
fn parse_zoned_compat_accepts_legacy_rfc3339_format() {
let zoned = parse_zoned_compat("2024-01-01T00:00:00+00:00").expect("legacy format should parse");
assert_eq!(zoned.time_zone().iana_name(), Some("UTC"));
}
}
+1
View File
@@ -32,6 +32,7 @@ path = "src/main.rs"
[dependencies]
# AWS SDK for S3 operations
aws-sdk-s3.workspace = true
aws-smithy-http-client.workspace = true
# Async runtime and utilities
tokio = { workspace = true, features = ["io-std", "io-util", "macros", "signal"] }
+9
View File
@@ -16,6 +16,7 @@ use anyhow::{Context, Result};
use aws_sdk_s3::config::{Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::{Client, Config as S3Config};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::io::AsyncWriteExt;
@@ -133,6 +134,14 @@ impl S3Client {
.region(Region::new(config.region.clone()))
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest());
if config
.endpoint_url
.as_deref()
.is_some_and(|endpoint| endpoint.starts_with("http://"))
{
config_builder = config_builder.http_client(SmithyHttpClientBuilder::new().build_http());
}
// Set force path style if custom endpoint or explicitly requested
let should_force_path_style = config.endpoint_url.is_some() || config.force_path_style;
if should_force_path_style {
+2
View File
@@ -629,6 +629,8 @@ mod tests {
let config = Config {
access_key_id: Some("test_key".to_string()),
secret_access_key: Some("test_secret".to_string()),
endpoint_url: Some("http://127.0.0.1:9000".to_string()),
force_path_style: true,
..Config::default()
};
-6
View File
@@ -78,12 +78,6 @@ pub mod notifier_global {
return;
}
// Check if any subscribers are interested in the event
if !notification_sys.has_subscriber(&args.bucket_name, &args.event_name).await {
// error!("No subscribers for event: {} in bucket: {}", args.event_name, args.bucket_name);
return;
}
// Create an event and send it
let event = Arc::new(Event::new(args));
notification_sys.send_event(event).await;
+109 -1
View File
@@ -25,12 +25,62 @@ use rustfs_targets::arn::TargetID;
use rustfs_targets::store::{Key, Store};
use rustfs_targets::target::EntityTarget;
use rustfs_targets::{StoreError, Target};
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::{RwLock, Semaphore, mpsc};
use tokio::sync::{RwLock, Semaphore, broadcast, mpsc};
use tracing::{debug, error, info, warn};
const MAX_RECENT_LIVE_EVENTS: usize = 1024;
#[derive(Clone)]
pub struct LiveEventBatch {
pub events: Vec<Arc<Event>>,
pub next_sequence: u64,
pub truncated: bool,
}
#[derive(Default)]
struct LiveEventHistory {
next_sequence: u64,
events: VecDeque<(u64, Arc<Event>)>,
}
impl LiveEventHistory {
fn record(&mut self, event: Arc<Event>) {
self.next_sequence = self.next_sequence.saturating_add(1);
self.events.push_back((self.next_sequence, event));
while self.events.len() > MAX_RECENT_LIVE_EVENTS {
self.events.pop_front();
}
}
fn snapshot_since(&self, after_sequence: u64, limit: usize) -> LiveEventBatch {
let mut events = Vec::new();
let mut next_sequence = after_sequence;
let mut truncated = false;
for (sequence, event) in self.events.iter() {
if *sequence <= after_sequence {
continue;
}
if events.len() >= limit {
truncated = true;
break;
}
next_sequence = *sequence;
events.push(event.clone());
}
LiveEventBatch {
events,
next_sequence,
truncated,
}
}
}
/// Notify the system of monitoring indicators
pub struct NotificationMetrics {
/// The number of events currently being processed
@@ -108,6 +158,10 @@ pub struct NotificationSystem {
metrics: Arc<NotificationMetrics>,
/// Subscriber view
subscriber_view: NotificationSystemSubscriberView,
/// Live event fan-out for in-process streaming consumers.
live_event_sender: broadcast::Sender<Arc<Event>>,
/// Recent live event history for peer fan-in consumers.
live_event_history: Arc<RwLock<LiveEventHistory>>,
}
impl NotificationSystem {
@@ -115,6 +169,7 @@ impl NotificationSystem {
pub fn new(config: Config) -> Self {
let concurrency_limiter =
rustfs_utils::get_env_usize(ENV_NOTIFY_TARGET_STREAM_CONCURRENCY, DEFAULT_NOTIFY_TARGET_STREAM_CONCURRENCY);
let (live_event_sender, _) = broadcast::channel(1024);
NotificationSystem {
subscriber_view: NotificationSystemSubscriberView::new(),
notifier: Arc::new(EventNotifier::new()),
@@ -123,6 +178,8 @@ impl NotificationSystem {
stream_cancellers: Arc::new(RwLock::new(HashMap::new())),
concurrency_limiter: Arc::new(Semaphore::new(concurrency_limiter)), // Limit the maximum number of concurrent processing events to 20
metrics: Arc::new(NotificationMetrics::new()),
live_event_sender,
live_event_history: Arc::new(RwLock::new(LiveEventHistory::default())),
}
}
@@ -216,6 +273,21 @@ impl NotificationSystem {
self.notifier.has_subscriber(bucket, event).await
}
/// Returns true when at least one in-process consumer is subscribed to live events.
pub fn has_live_listeners(&self) -> bool {
self.live_event_sender.receiver_count() > 0
}
/// Subscribes to the in-process live event stream.
pub fn subscribe_live_events(&self) -> broadcast::Receiver<Arc<Event>> {
self.live_event_sender.subscribe()
}
pub async fn recent_live_events_since(&self, after_sequence: u64, limit: usize) -> LiveEventBatch {
let history = self.live_event_history.read().await;
history.snapshot_since(after_sequence, limit.max(1))
}
async fn update_config_and_reload<F>(&self, mut modifier: F) -> Result<(), NotificationError>
where
F: FnMut(&mut Config) -> bool, // The closure returns a boolean value indicating whether the configuration has been changed
@@ -500,6 +572,8 @@ impl NotificationSystem {
/// Sends an event
pub async fn send_event(&self, event: Arc<Event>) {
self.live_event_history.write().await.record(event.clone());
let _ = self.live_event_sender.send(event.clone());
self.notifier.send(event).await;
}
@@ -558,3 +632,37 @@ pub async fn load_config_from_file(path: &str, system: &NotificationSystem) -> R
.map_err(|e| NotificationError::Configuration(format!("Failed to parse config: {e}")))?;
system.reload_config(config).await
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_s3_common::EventName;
#[test]
fn live_event_history_snapshots_from_sequence() {
let mut history = LiveEventHistory::default();
history.record(Arc::new(Event::new_test_event("bucket", "one", EventName::ObjectCreatedPut)));
history.record(Arc::new(Event::new_test_event("bucket", "two", EventName::ObjectCreatedPut)));
let batch = history.snapshot_since(1, 16);
assert_eq!(batch.next_sequence, 2);
assert!(!batch.truncated);
assert_eq!(batch.events.len(), 1);
assert_eq!(batch.events[0].s3.object.key, "two");
}
#[test]
fn live_event_history_marks_truncation() {
let mut history = LiveEventHistory::default();
history.record(Arc::new(Event::new_test_event("bucket", "one", EventName::ObjectCreatedPut)));
history.record(Arc::new(Event::new_test_event("bucket", "two", EventName::ObjectCreatedPut)));
let batch = history.snapshot_since(0, 1);
assert_eq!(batch.next_sequence, 1);
assert!(batch.truncated);
assert_eq!(batch.events.len(), 1);
assert_eq!(batch.events[0].s3.object.key, "one");
}
}
@@ -796,6 +796,26 @@ pub struct GetMetricsResponse {
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetLiveEventsRequest {
#[prost(uint64, tag = "1")]
pub after_sequence: u64,
#[prost(uint32, tag = "2")]
pub limit: u32,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetLiveEventsResponse {
#[prost(bool, tag = "1")]
pub success: bool,
#[prost(bytes = "bytes", tag = "2")]
pub events: ::prost::bytes::Bytes,
#[prost(uint64, tag = "3")]
pub next_sequence: u64,
#[prost(bool, tag = "4")]
pub truncated: bool,
#[prost(string, optional, tag = "5")]
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetProcInfoRequest {}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetProcInfoResponse {
@@ -1921,6 +1941,21 @@ pub mod node_service_client {
.insert(GrpcMethod::new("node_service.NodeService", "GetMetrics"));
self.inner.unary(req, path, codec).await
}
pub async fn get_live_events(
&mut self,
request: impl tonic::IntoRequest<super::GetLiveEventsRequest>,
) -> std::result::Result<tonic::Response<super::GetLiveEventsResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/GetLiveEvents");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.NodeService", "GetLiveEvents"));
self.inner.unary(req, path, codec).await
}
pub async fn get_proc_info(
&mut self,
request: impl tonic::IntoRequest<super::GetProcInfoRequest>,
@@ -2521,6 +2556,10 @@ pub mod node_service_server {
&self,
request: tonic::Request<super::GetMetricsRequest>,
) -> std::result::Result<tonic::Response<super::GetMetricsResponse>, tonic::Status>;
async fn get_live_events(
&self,
request: tonic::Request<super::GetLiveEventsRequest>,
) -> std::result::Result<tonic::Response<super::GetLiveEventsResponse>, tonic::Status>;
async fn get_proc_info(
&self,
request: tonic::Request<super::GetProcInfoRequest>,
@@ -4097,6 +4136,34 @@ pub mod node_service_server {
};
Box::pin(fut)
}
"/node_service.NodeService/GetLiveEvents" => {
#[allow(non_camel_case_types)]
struct GetLiveEventsSvc<T: NodeService>(pub Arc<T>);
impl<T: NodeService> tonic::server::UnaryService<super::GetLiveEventsRequest> for GetLiveEventsSvc<T> {
type Response = super::GetLiveEventsResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::GetLiveEventsRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move { <T as NodeService>::get_live_events(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetLiveEventsSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.NodeService/GetProcInfo" => {
#[allow(non_camel_case_types)]
struct GetProcInfoSvc<T: NodeService>(pub Arc<T>);
+14
View File
@@ -773,6 +773,19 @@ message LoadTransitionTierConfigResponse {
optional string error_info = 2;
}
message GetLiveEventsRequest {
uint64 after_sequence = 1;
uint32 limit = 2;
}
message GetLiveEventsResponse {
bool success = 1;
bytes events = 2;
uint64 next_sequence = 3;
bool truncated = 4;
optional string error_info = 5;
}
/* -------------------------------------------------------------------- */
service NodeService {
@@ -865,4 +878,5 @@ service NodeService {
rpc StopRebalance(StopRebalanceRequest) returns (StopRebalanceResponse) {};
rpc LoadRebalanceMeta(LoadRebalanceMetaRequest) returns (LoadRebalanceMetaResponse) {};
rpc LoadTransitionTierConfig(LoadTransitionTierConfigRequest) returns (LoadTransitionTierConfigResponse) {};
rpc GetLiveEvents(GetLiveEventsRequest) returns (GetLiveEventsResponse) {};
}
+132 -9
View File
@@ -290,18 +290,20 @@ where
Poll::Ready(Ok(())) => {
let n = temp_buf.filled().len();
if n == 0 {
*this.finished = true;
return Poll::Ready(Ok(()));
if *this.header_read == 0 {
*this.finished = true;
return Poll::Ready(Ok(()));
}
return Poll::Ready(Err(Error::new(
std::io::ErrorKind::UnexpectedEof,
"unexpected EOF while reading encrypted block header",
)));
}
this.header_buf[*this.header_read..*this.header_read + n].copy_from_slice(&temp_buf.filled()[..n]);
*this.header_read += n;
}
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
}
if *this.header_read < 8 {
return Poll::Pending;
}
}
if !*this.header_done && *this.header_read == 8 {
@@ -374,7 +376,10 @@ where
Poll::Ready(Ok(())) => {
let n = temp_buf.filled().len();
if n == 0 {
break;
return Poll::Ready(Err(Error::new(
std::io::ErrorKind::UnexpectedEof,
"unexpected EOF while reading encrypted block payload",
)));
}
*this.ciphertext_read += n;
}
@@ -483,12 +488,50 @@ fn derive_part_nonce(base: &[u8; 12], part_number: usize) -> [u8; 12] {
#[cfg(test)]
mod tests {
use std::io::Cursor;
use std::pin::Pin;
use std::task::{Context, Poll};
use crate::WarpReader;
use crate::{HardLimitReader, WarpReader};
use super::*;
use futures::StreamExt;
use rand::{Rng, RngExt};
use tokio::io::{AsyncReadExt, BufReader};
use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};
use tokio_util::io::ReaderStream;
struct ChunkedCursor {
inner: Cursor<Vec<u8>>,
max_chunk: usize,
}
impl ChunkedCursor {
fn new(data: Vec<u8>, max_chunk: usize) -> Self {
Self {
inner: Cursor::new(data),
max_chunk,
}
}
}
impl AsyncRead for ChunkedCursor {
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if self.max_chunk == 0 || buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let remaining = self.inner.get_ref().len() as u64 - self.inner.position();
if remaining == 0 {
return Poll::Ready(Ok(()));
}
let to_read = remaining.min(self.max_chunk as u64).min(buf.remaining() as u64) as usize;
let start = self.inner.position() as usize;
let end = start + to_read;
buf.put_slice(&self.inner.get_ref()[start..end]);
self.inner.set_position(end as u64);
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn test_encrypt_decrypt_reader_aes256gcm() {
@@ -569,6 +612,86 @@ mod tests {
assert_eq!(&decrypted, &data);
}
#[tokio::test]
async fn test_decrypt_reader_large_with_small_chunks() {
let size = 1024 * 1024;
let mut data = vec![0u8; size];
rand::rng().fill(&mut data[..]);
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let reader = Cursor::new(data.clone());
let mut encrypt_reader = EncryptReader::new(WarpReader::new(reader), key, nonce);
let mut encrypted = Vec::new();
encrypt_reader.read_to_end(&mut encrypted).await.unwrap();
let reader = ChunkedCursor::new(encrypted, 3);
let mut decrypt_reader = DecryptReader::new(WarpReader::new(reader), key, nonce);
let mut decrypted = Vec::new();
decrypt_reader.read_to_end(&mut decrypted).await.unwrap();
assert_eq!(decrypted, data);
}
#[tokio::test]
async fn test_decrypt_reader_large_through_reader_stream() {
let size = 1024 * 1024;
let mut data = vec![0u8; size];
rand::rng().fill(&mut data[..]);
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let reader = Cursor::new(data.clone());
let mut encrypt_reader = EncryptReader::new(WarpReader::new(reader), key, nonce);
let mut encrypted = Vec::new();
encrypt_reader.read_to_end(&mut encrypted).await.unwrap();
let reader = ChunkedCursor::new(encrypted, 8192);
let decrypt_reader = DecryptReader::new(WarpReader::new(reader), key, nonce);
let mut stream = ReaderStream::with_capacity(Box::new(decrypt_reader), 262_144);
let mut decrypted = Vec::new();
while let Some(chunk) = stream.next().await {
let bytes = chunk.unwrap();
decrypted.extend_from_slice(&bytes);
}
assert_eq!(decrypted, data);
}
#[tokio::test]
async fn test_decrypt_reader_large_through_hard_limit_reader_stream() {
let size = 1024 * 1024;
let mut data = vec![0u8; size];
rand::rng().fill(&mut data[..]);
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let reader = Cursor::new(data.clone());
let mut encrypt_reader = EncryptReader::new(WarpReader::new(reader), key, nonce);
let mut encrypted = Vec::new();
encrypt_reader.read_to_end(&mut encrypted).await.unwrap();
let reader = ChunkedCursor::new(encrypted, 8192);
let decrypt_reader = DecryptReader::new(WarpReader::new(reader), key, nonce);
let limit_reader = HardLimitReader::new(Box::new(decrypt_reader), size as i64);
let mut stream = ReaderStream::with_capacity(Box::new(limit_reader), 262_144);
let mut decrypted = Vec::new();
while let Some(chunk) = stream.next().await {
let bytes = chunk.unwrap();
decrypted.extend_from_slice(&bytes);
}
assert_eq!(decrypted, data);
}
#[tokio::test]
async fn test_decrypt_reader_multipart_segments() {
let mut key = [0u8; 32];
+10 -4
View File
@@ -45,12 +45,12 @@ impl CompressionFormat {
/// Identify compression format from file extension
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"gz" | "gzip" => CompressionFormat::Gzip,
"bz2" | "bzip2" => CompressionFormat::Bzip2,
"gz" | "gzip" | "tgz" => CompressionFormat::Gzip,
"bz2" | "bzip2" | "tbz" | "tbz2" => CompressionFormat::Bzip2,
"zip" => CompressionFormat::Zip,
"xz" => CompressionFormat::Xz,
"xz" | "txz" => CompressionFormat::Xz,
"zlib" => CompressionFormat::Zlib,
"zst" | "zstd" => CompressionFormat::Zstd,
"zst" | "zstd" | "tzst" => CompressionFormat::Zstd,
"tar" => CompressionFormat::Tar,
_ => CompressionFormat::Unknown,
}
@@ -301,17 +301,23 @@ mod tests {
// Test supported compression format recognition
assert_eq!(CompressionFormat::from_extension("gz"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("gzip"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("tgz"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("bz2"), CompressionFormat::Bzip2);
assert_eq!(CompressionFormat::from_extension("bzip2"), CompressionFormat::Bzip2);
assert_eq!(CompressionFormat::from_extension("tbz"), CompressionFormat::Bzip2);
assert_eq!(CompressionFormat::from_extension("tbz2"), CompressionFormat::Bzip2);
assert_eq!(CompressionFormat::from_extension("zip"), CompressionFormat::Zip);
assert_eq!(CompressionFormat::from_extension("xz"), CompressionFormat::Xz);
assert_eq!(CompressionFormat::from_extension("txz"), CompressionFormat::Xz);
assert_eq!(CompressionFormat::from_extension("zlib"), CompressionFormat::Zlib);
assert_eq!(CompressionFormat::from_extension("zst"), CompressionFormat::Zstd);
assert_eq!(CompressionFormat::from_extension("zstd"), CompressionFormat::Zstd);
assert_eq!(CompressionFormat::from_extension("tzst"), CompressionFormat::Zstd);
assert_eq!(CompressionFormat::from_extension("tar"), CompressionFormat::Tar);
// Test case insensitivity
assert_eq!(CompressionFormat::from_extension("GZ"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("TGZ"), CompressionFormat::Gzip);
assert_eq!(CompressionFormat::from_extension("ZIP"), CompressionFormat::Zip);
// Test unknown formats
+2 -1
View File
@@ -89,6 +89,7 @@ reqwest = { workspace = true }
socket2 = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "process", "io-util"] }
tokio-rustls = { workspace = true }
aws-sdk-s3 = { workspace = true }
tokio-stream.workspace = true
tokio-util.workspace = true
tonic = { workspace = true }
@@ -99,6 +100,7 @@ tower-http = { workspace = true, features = ["trace", "compression-full", "cors"
bytes = { workspace = true }
flatbuffers.workspace = true
rmp-serde.workspace = true
rustfs-signer.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_urlencoded = { workspace = true }
@@ -167,7 +169,6 @@ pprof = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
serial_test = { workspace = true }
tempfile = { workspace = true }
aws-sdk-s3 = { workspace = true }
aws-config = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
+39 -26
View File
@@ -23,7 +23,7 @@ use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
use rustfs_credentials::Credentials;
use rustfs_ecstore::bucket::bucket_target_sys::BucketTargetSys;
use rustfs_ecstore::bucket::bucket_target_sys::{BucketTargetError, BucketTargetSys};
use rustfs_ecstore::bucket::metadata::BUCKET_TARGETS_FILE;
use rustfs_ecstore::bucket::metadata_sys;
use rustfs_ecstore::bucket::metadata_sys::get_replication_config;
@@ -53,6 +53,22 @@ fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
params
}
fn map_bucket_target_error(err: BucketTargetError) -> S3Error {
match err {
BucketTargetError::BucketRemoteTargetNotFound { .. }
| BucketTargetError::BucketRemoteArnTypeInvalid { .. }
| BucketTargetError::BucketRemoteAlreadyExists { .. }
| BucketTargetError::BucketRemoteArnInvalid { .. }
| BucketTargetError::RemoteTargetConnectionErr { .. }
| BucketTargetError::BucketReplicationSourceNotVersioned { .. }
| BucketTargetError::BucketRemoteTargetNotVersioned { .. }
| BucketTargetError::BucketRemoteRemoveDisallowed { .. } => {
S3Error::with_message(S3ErrorCode::InvalidRequest, err.to_string())
}
BucketTargetError::Io(io_err) => S3Error::with_message(S3ErrorCode::InternalError, io_err.to_string()),
}
}
pub fn register_replication_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
Method::GET,
@@ -200,7 +216,7 @@ impl Operation for SetRemoteTargetHandler {
})?;
let Ok(target_url) = remote_target.url() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Invalid target url".to_string()));
return Err(s3_error!(InvalidRequest, "invalid target url"));
};
let same_target = rustfs_utils::net::is_local_host(
@@ -232,7 +248,7 @@ impl Operation for SetRemoteTargetHandler {
}
if remote_target.arn.is_empty() {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "ARN is empty".to_string()));
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "ARN is empty".to_string()));
}
if update {
@@ -240,7 +256,7 @@ impl Operation for SetRemoteTargetHandler {
.get_remote_bucket_target_by_arn(bucket, &remote_target.arn)
.await
else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Target not found".to_string()));
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "Target not found".to_string()));
};
target.credentials = remote_target.credentials;
@@ -262,7 +278,7 @@ impl Operation for SetRemoteTargetHandler {
bucket_target_sys
.set_target(bucket, &remote_target, update)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e.to_string()))?;
.map_err(map_bucket_target_error)?;
let targets = bucket_target_sys.list_bucket_targets(bucket).await.map_err(|e| {
error!("Failed to list bucket targets: {}", e);
@@ -302,20 +318,17 @@ impl Operation for ListRemoteTargetHandler {
if let Some(bucket) = queries.get("bucket") {
if bucket.is_empty() {
error!("bucket parameter is empty");
return Ok(S3Response::new((
StatusCode::BAD_REQUEST,
Body::from("Bucket parameter is required".to_string()),
)));
return Err(s3_error!(InvalidRequest, "bucket is required"));
}
let Some(store) = new_object_layer_fn() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not initialized".to_string()));
};
if let Err(err) = store.get_bucket_info(bucket, &BucketOptions::default()).await {
error!("Error fetching bucket info: {:?}", err);
return Ok(S3Response::new((StatusCode::BAD_REQUEST, Body::from("Invalid bucket".to_string()))));
}
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let sys = BucketTargetSys::get();
let targets = sys.list_targets(bucket, "").await;
@@ -355,31 +368,31 @@ impl Operation for RemoveRemoteTargetHandler {
debug!("remove remote target called");
let queries = extract_query_params(&req.uri);
let Some(bucket) = queries.get("bucket") else {
return Ok(S3Response::new((
StatusCode::BAD_REQUEST,
Body::from("Bucket parameter is required".to_string()),
)));
return Err(s3_error!(InvalidRequest, "bucket is required"));
};
if bucket.is_empty() {
return Err(s3_error!(InvalidRequest, "bucket is required"));
}
let Some(arn_str) = queries.get("arn") else {
return Ok(S3Response::new((StatusCode::BAD_REQUEST, Body::from("ARN is required".to_string()))));
return Err(s3_error!(InvalidRequest, "arn is required"));
};
if arn_str.is_empty() {
return Err(s3_error!(InvalidRequest, "arn is required"));
};
let Some(store) = new_object_layer_fn() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not initialized".to_string()));
};
if let Err(err) = store.get_bucket_info(bucket, &BucketOptions::default()).await {
error!("Error fetching bucket info: {:?}", err);
return Ok(S3Response::new((StatusCode::BAD_REQUEST, Body::from("Invalid bucket".to_string()))));
}
store
.get_bucket_info(bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let sys = BucketTargetSys::get();
sys.remove_target(bucket, arn_str).await.map_err(|e| {
error!("Failed to remove target: {}", e);
S3Error::with_message(S3ErrorCode::InternalError, "Failed to remove target".to_string())
})?;
sys.remove_target(bucket, arn_str).await.map_err(map_bucket_target_error)?;
let targets = sys.list_bucket_targets(bucket).await.map_err(|e| {
error!("Failed to list bucket targets: {}", e);
File diff suppressed because it is too large Load Diff
+37 -13
View File
@@ -34,6 +34,7 @@ use rustfs_ecstore::bucket::{
metadata_sys,
quota::QuotaOperation,
replication::{get_must_replicate_options, must_replicate, schedule_replication},
versioning_sys::BucketVersioningSys,
};
use rustfs_ecstore::client::object_api_utils::to_s3s_etag;
use rustfs_ecstore::compress::is_compressible;
@@ -315,10 +316,13 @@ impl DefaultMultipartUsecase {
server_side_encryption
);
let ssekms_key_id = multipart_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.cloned();
let ssekms_key_id = match server_side_encryption.as_ref() {
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => multipart_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.cloned(),
_ => None,
};
info!(
"TDD: Extracted encryption info - SSE: {:?}, KMS Key: {:?}",
@@ -367,7 +371,12 @@ impl DefaultMultipartUsecase {
let manager = get_concurrency_manager();
let mpu_bucket = bucket.clone();
let mpu_key = key.clone();
let mpu_version = obj_info.version_id.map(|v| v.to_string());
let raw_mpu_version = obj_info.version_id.map(|v| v.to_string());
let mpu_version = if BucketVersioningSys::prefix_enabled(&bucket, &key).await {
raw_mpu_version.clone()
} else {
None
};
let mpu_version_clone = mpu_version.clone();
let mpu_version_for_event = mpu_version.clone();
tokio::spawn(async move {
@@ -495,6 +504,14 @@ impl DefaultMultipartUsecase {
..
} = req.input.clone();
let server_side_encryption = server_side_encryption.or(extract_server_side_encryption_from_headers(&req.headers)?);
let ssekms_key_id = ssekms_key_id.or_else(|| {
req.headers
.get("x-amz-server-side-encryption-aws-kms-key-id")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
});
// Validate storage class if provided
if let Some(ref storage_class) = storage_class
&& !is_valid_storage_class(storage_class.as_str())
@@ -708,10 +725,13 @@ impl DefaultMultipartUsecase {
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid server-side encryption: {e}"))))
})
.transpose()?;
let key_id = fi
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string());
let key_id = match sse.as_ref() {
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => fi
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string()),
_ => None,
};
(sse, key_id)
};
let part_key = fi.user_defined.get("x-rustfs-encryption-key").cloned();
@@ -1045,6 +1065,7 @@ impl DefaultMultipartUsecase {
sse_customer_key_md5: copy_source_sse_customer_key_md5.as_ref(),
part_number: None,
parts: &src_info.parts,
etag: src_info.etag.as_deref(),
};
if let Some(material) = sse_decryption(src_decryption_request).await? {
@@ -1073,10 +1094,13 @@ impl DefaultMultipartUsecase {
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid server-side encryption: {e}"))))
})
.transpose()?;
let ssekms_key_id = mp_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string());
let ssekms_key_id = match server_side_encryption.as_ref() {
Some(sse) if sse.as_str() == ServerSideEncryption::AWS_KMS => mp_info
.user_defined
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(|s| s.to_string()),
_ => None,
};
let part_key = mp_info.user_defined.get("x-rustfs-encryption-key").cloned();
let part_nonce = mp_info.user_defined.get("x-rustfs-encryption-iv").cloned();
let encryption_request = EncryptionRequest {
File diff suppressed because it is too large Load Diff
+43
View File
@@ -13,12 +13,54 @@
// limitations under the License.
use crate::app::context::resolve_server_config;
use rustfs_ecstore::event_notification::{EventArgs as EcstoreEventArgs, register_event_dispatch_hook};
use rustfs_notify::EventArgs as NotifyEventArgs;
use rustfs_s3_common::EventName;
use tokio::spawn;
use tracing::{error, info, instrument, warn};
fn server_config_from_context() -> Option<rustfs_ecstore::config::Config> {
resolve_server_config()
}
fn convert_ecstore_event_args(args: EcstoreEventArgs) -> NotifyEventArgs {
let version_id = args.object.version_id.map(|v| v.to_string()).unwrap_or_default();
let (host, port) = match args.host.rsplit_once(':') {
Some((host, port)) => match port.parse::<u16>() {
Ok(port) => (host.to_string(), port),
Err(_) => (args.host, 0),
},
None => (args.host, 0),
};
let req_params = args.req_params.into_iter().collect();
let resp_elements = args.resp_elements.into_iter().collect();
NotifyEventArgs {
event_name: EventName::from(args.event_name.as_str()),
bucket_name: args.bucket_name,
object: args.object,
req_params,
resp_elements,
version_id,
host,
port,
user_agent: args.user_agent,
}
}
fn install_ecstore_event_dispatch_hook() {
let installed = register_event_dispatch_hook(|args| {
let notify_args = convert_ecstore_event_args(args);
spawn(async move {
rustfs_notify::notifier_global::notify(notify_args).await;
});
});
if !installed {
warn!("ECStore event dispatch hook was already registered");
}
}
/// Shuts down the event notifier system gracefully
pub(crate) async fn shutdown_event_notifier() {
info!("Shutting down event notifier system...");
@@ -67,6 +109,7 @@ pub(crate) async fn init_event_notifier() {
if let Err(e) = rustfs_notify::initialize(server_config).await {
error!("Failed to initialize event notifier system: {}", e);
} else {
install_ecstore_event_dispatch_hook();
info!(
target: "rustfs::main::init_event_notifier",
"Event notifier system initialized successfully."
+227 -22
View File
@@ -29,6 +29,7 @@ use rustfs_utils::http::AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE;
use s3s::access::{S3Access, S3AccessContext};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Result, dto::*, s3_error};
use std::collections::HashMap;
use url::Url;
#[derive(Default, Clone, Debug)]
pub(crate) struct ReqInfo {
@@ -41,6 +42,9 @@ pub(crate) struct ReqInfo {
pub region: Option<s3s::region::Region>,
}
#[derive(Clone, Debug)]
pub(crate) struct PostObjectRequestMarker;
pub(crate) fn req_info_ref<T>(req: &S3Request<T>) -> S3Result<&ReqInfo> {
req.extensions
.get::<ReqInfo>()
@@ -359,6 +363,35 @@ fn put_bucket_policy_authorize_action() -> Action {
Action::S3Action(S3Action::PutBucketPolicyAction)
}
fn post_object_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAction)
}
fn complete_multipart_upload_authorize_action() -> Action {
Action::S3Action(S3Action::PutObjectAction)
}
fn list_parts_authorize_action() -> Action {
Action::S3Action(S3Action::ListMultipartUploadPartsAction)
}
fn validate_post_object_success_controls(input: &PostObjectInput) -> S3Result<()> {
if let Some(status) = input.success_action_status
&& !matches!(status, 200 | 201 | 204)
{
return Err(s3_error!(MalformedPOSTRequest, "success_action_status must be one of 200, 201, or 204"));
}
if let Some(redirect) = input.success_action_redirect.as_deref().map(str::trim)
&& !redirect.is_empty()
&& Url::parse(redirect).is_err()
{
return Err(s3_error!(MalformedPOSTRequest, "success_action_redirect must be a valid absolute URL"));
}
Ok(())
}
#[async_trait::async_trait]
impl S3Access for FS {
// /// Checks whether the current request has accesses to the resources.
@@ -437,15 +470,23 @@ impl S3Access for FS {
/// Checks whether the AbortMultipartUpload request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn abort_multipart_upload(&self, _req: &mut S3Request<AbortMultipartUploadInput>) -> S3Result<()> {
Ok(())
async fn abort_multipart_upload(&self, req: &mut S3Request<AbortMultipartUploadInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, Action::S3Action(S3Action::AbortMultipartUploadAction)).await
}
/// Checks whether the CompleteMultipartUpload request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn complete_multipart_upload(&self, _req: &mut S3Request<CompleteMultipartUploadInput>) -> S3Result<()> {
Ok(())
async fn complete_multipart_upload(&self, req: &mut S3Request<CompleteMultipartUploadInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, complete_multipart_upload_authorize_action()).await
}
/// Checks whether the CopyObject request has accesses to the resources.
@@ -617,8 +658,11 @@ impl S3Access for FS {
/// Checks whether the DeleteBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn delete_bucket_website(&self, _req: &mut S3Request<DeleteBucketWebsiteInput>) -> S3Result<()> {
Ok(())
async fn delete_bucket_website(&self, req: &mut S3Request<DeleteBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the DeleteObject request has accesses to the resources.
@@ -708,9 +752,12 @@ impl S3Access for FS {
/// This method returns `Ok(())` by default.
async fn get_bucket_accelerate_configuration(
&self,
_req: &mut S3Request<GetBucketAccelerateConfigurationInput>,
req: &mut S3Request<GetBucketAccelerateConfigurationInput>,
) -> S3Result<()> {
Ok(())
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetBucketAcl request has accesses to the resources.
@@ -866,8 +913,11 @@ impl S3Access for FS {
/// Checks whether the GetBucketRequestPayment request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_request_payment(&self, _req: &mut S3Request<GetBucketRequestPaymentInput>) -> S3Result<()> {
Ok(())
async fn get_bucket_request_payment(&self, req: &mut S3Request<GetBucketRequestPaymentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetBucketTagging request has accesses to the resources.
@@ -893,8 +943,11 @@ impl S3Access for FS {
/// Checks whether the GetBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn get_bucket_website(&self, _req: &mut S3Request<GetBucketWebsiteInput>) -> S3Result<()> {
Ok(())
async fn get_bucket_website(&self, req: &mut S3Request<GetBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::GetBucketPolicyAction)).await
}
/// Checks whether the GetObject request has accesses to the resources.
@@ -1142,8 +1195,25 @@ impl S3Access for FS {
/// Checks whether the ListParts request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn list_parts(&self, _req: &mut S3Request<ListPartsInput>) -> S3Result<()> {
Ok(())
async fn list_parts(&self, req: &mut S3Request<ListPartsInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
authorize_request(req, list_parts_authorize_action()).await
}
/// Checks whether the PostObject request has accesses to the resources.
async fn post_object(&self, req: &mut S3Request<PostObjectInput>) -> S3Result<()> {
validate_post_object_success_controls(&req.input)?;
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = req.input.version_id.clone();
req.extensions.insert(PostObjectRequestMarker);
authorize_request(req, post_object_authorize_action()).await
}
/// Checks whether the PutBucketAccelerateConfiguration request has accesses to the resources.
@@ -1151,9 +1221,12 @@ impl S3Access for FS {
/// This method returns `Ok(())` by default.
async fn put_bucket_accelerate_configuration(
&self,
_req: &mut S3Request<PutBucketAccelerateConfigurationInput>,
req: &mut S3Request<PutBucketAccelerateConfigurationInput>,
) -> S3Result<()> {
Ok(())
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutBucketAcl request has accesses to the resources.
@@ -1286,8 +1359,11 @@ impl S3Access for FS {
/// Checks whether the PutBucketRequestPayment request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_request_payment(&self, _req: &mut S3Request<PutBucketRequestPaymentInput>) -> S3Result<()> {
Ok(())
async fn put_bucket_request_payment(&self, req: &mut S3Request<PutBucketRequestPaymentInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutBucketTagging request has accesses to the resources.
@@ -1313,8 +1389,11 @@ impl S3Access for FS {
/// Checks whether the PutBucketWebsite request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn put_bucket_website(&self, _req: &mut S3Request<PutBucketWebsiteInput>) -> S3Result<()> {
Ok(())
async fn put_bucket_website(&self, req: &mut S3Request<PutBucketWebsiteInput>) -> S3Result<()> {
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
authorize_request(req, Action::S3Action(S3Action::PutBucketPolicyAction)).await
}
/// Checks whether the PutObject request has accesses to the resources.
@@ -1444,8 +1523,35 @@ impl S3Access for FS {
/// Checks whether the UploadPartCopy request has accesses to the resources.
///
/// This method returns `Ok(())` by default.
async fn upload_part_copy(&self, _req: &mut S3Request<UploadPartCopyInput>) -> S3Result<()> {
Ok(())
async fn upload_part_copy(&self, req: &mut S3Request<UploadPartCopyInput>) -> S3Result<()> {
{
let (src_bucket, src_key, version_id) = match &req.input.copy_source {
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
CopySource::Bucket { bucket, key, version_id } => {
(bucket.to_string(), key.to_string(), version_id.as_ref().map(|v| v.to_string()))
}
};
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(src_bucket.clone());
req_info.object = Some(src_key.clone());
req_info.version_id = version_id.clone();
let tag_conds = self
.fetch_tag_conditions(&src_bucket, &src_key, version_id.as_deref(), "upload_part_copy_src")
.await?;
req.extensions.insert(tag_conds);
authorize_request(req, Action::S3Action(S3Action::GetObjectAction)).await?;
}
let req_info = ext_req_info_mut(&mut req.extensions)?;
req_info.bucket = Some(req.input.bucket.clone());
req_info.object = Some(req.input.key.clone());
req_info.version_id = None;
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await
}
/// Checks whether the WriteGetObjectResponse request has accesses to the resources.
@@ -1459,6 +1565,7 @@ impl S3Access for FS {
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderMap, Method, Uri};
use std::collections::HashMap;
#[test]
@@ -1471,6 +1578,74 @@ mod tests {
assert_eq!(put_bucket_policy_authorize_action(), Action::S3Action(S3Action::PutBucketPolicyAction));
}
#[test]
fn post_object_uses_put_object_action() {
assert_eq!(post_object_authorize_action(), Action::S3Action(S3Action::PutObjectAction));
}
#[test]
fn complete_multipart_upload_uses_put_object_action() {
assert_eq!(complete_multipart_upload_authorize_action(), Action::S3Action(S3Action::PutObjectAction));
}
#[test]
fn list_parts_uses_list_multipart_upload_parts_action() {
assert_eq!(list_parts_authorize_action(), Action::S3Action(S3Action::ListMultipartUploadPartsAction));
}
#[test]
fn validate_post_object_success_controls_accepts_supported_status_codes() {
for status in [200, 201, 204] {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_status(Some(status))
.build()
.expect("post object input should build");
assert!(
validate_post_object_success_controls(&input).is_ok(),
"status {status} should be accepted"
);
}
}
#[test]
fn validate_post_object_success_controls_rejects_invalid_status_code() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_status(Some(202))
.build()
.expect("post object input should build");
let err = validate_post_object_success_controls(&input).expect_err("status 202 should be rejected");
assert_eq!(err.code(), &S3ErrorCode::MalformedPOSTRequest);
}
#[test]
fn validate_post_object_success_controls_accepts_empty_redirect() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_redirect(Some("".to_string()))
.build()
.expect("post object input should build");
assert!(validate_post_object_success_controls(&input).is_ok());
}
#[test]
fn validate_post_object_success_controls_rejects_invalid_redirect() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.success_action_redirect(Some("://invalid-url".to_string()))
.build()
.expect("post object input should build");
let err = validate_post_object_success_controls(&input).expect_err("invalid redirect should be rejected");
assert_eq!(err.code(), &S3ErrorCode::MalformedPOSTRequest);
}
/// Object tag conditions must use keys like ExistingObjectTag/<tag-key> so that
/// bucket policy conditions (e.g. s3:ExistingObjectTag/security) are evaluated correctly.
#[test]
@@ -1515,4 +1690,34 @@ mod tests {
&Action::S3Action(S3Action::DeleteBucketPolicyAction)
));
}
#[tokio::test]
async fn post_object_marks_request_extensions() {
let input = PostObjectInput::builder()
.bucket("test-bucket".to_string())
.key("test-key".to_string())
.build()
.expect("post object input should build");
let mut req = S3Request {
input,
method: Method::POST,
uri: Uri::from_static("/"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
req.extensions.insert(ReqInfo::default());
let fs = FS::new();
let _ = fs.post_object(&mut req).await;
assert!(
req.extensions.get::<PostObjectRequestMarker>().is_some(),
"post object request should carry the marker for downstream handling"
);
}
}
+172 -4
View File
@@ -16,8 +16,13 @@ use crate::app::bucket_usecase::DefaultBucketUsecase;
use crate::app::multipart_usecase::DefaultMultipartUsecase;
use crate::app::object_usecase::DefaultObjectUsecase;
use rustfs_ecstore::{
bucket::tagging::decode_tags_to_map,
error::{is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found},
bucket::{
metadata::{BUCKET_ACCELERATE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG, BUCKET_WEBSITE_CONFIG},
metadata_sys,
tagging::decode_tags_to_map,
utils::serialize,
},
error::{StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found},
new_object_layer_fn,
store_api::{BucketOperations, BucketOptions, ObjectOperations, ObjectOptions},
};
@@ -265,6 +270,26 @@ impl S3 for FS {
usecase.execute_delete_bucket_tagging(req).await
}
async fn delete_bucket_website(
&self,
req: S3Request<DeleteBucketWebsiteInput>,
) -> S3Result<S3Response<DeleteBucketWebsiteOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
metadata_sys::delete(&req.input.bucket, BUCKET_WEBSITE_CONFIG)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(DeleteBucketWebsiteOutput::default()))
}
#[instrument(level = "debug", skip(self))]
async fn delete_public_access_block(
&self,
@@ -303,6 +328,29 @@ impl S3 for FS {
usecase.execute_get_bucket_acl(req).await
}
async fn get_bucket_accelerate_configuration(
&self,
req: S3Request<GetBucketAccelerateConfigurationInput>,
) -> S3Result<S3Response<GetBucketAccelerateConfigurationOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match metadata_sys::get_accelerate_config(&req.input.bucket).await {
Ok((accelerate, _)) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput {
status: accelerate.status,
..Default::default()
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketAccelerateConfigurationOutput::default())),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
#[instrument(level = "debug", skip(self))]
async fn get_bucket_cors(&self, req: S3Request<GetBucketCorsInput>) -> S3Result<S3Response<GetBucketCorsOutput>> {
record_s3_op(S3Operation::GetBucketCors, &req.input.bucket);
@@ -370,6 +418,30 @@ impl S3 for FS {
usecase.execute_get_bucket_replication(req).await
}
async fn get_bucket_request_payment(
&self,
req: S3Request<GetBucketRequestPaymentInput>,
) -> S3Result<S3Response<GetBucketRequestPaymentOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match metadata_sys::get_request_payment_config(&req.input.bucket).await {
Ok((payment, _)) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
payer: Some(payment.payer),
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketRequestPaymentOutput {
payer: Some(Payer::from_static(Payer::BUCKET_OWNER)),
})),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
#[instrument(level = "debug", skip(self))]
async fn get_bucket_tagging(&self, req: S3Request<GetBucketTaggingInput>) -> S3Result<S3Response<GetBucketTaggingOutput>> {
record_s3_op(S3Operation::GetBucketTagging, &req.input.bucket);
@@ -397,6 +469,28 @@ impl S3 for FS {
usecase.execute_get_bucket_versioning(req).await
}
async fn get_bucket_website(&self, req: S3Request<GetBucketWebsiteInput>) -> S3Result<S3Response<GetBucketWebsiteOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
match metadata_sys::get_website_config(&req.input.bucket).await {
Ok((website, _)) => Ok(S3Response::new(GetBucketWebsiteOutput {
error_document: website.error_document,
index_document: website.index_document,
redirect_all_requests_to: website.redirect_all_requests_to,
routing_rules: website.routing_rules,
})),
Err(StorageError::ConfigNotFound) => Err(s3_error!(NoSuchWebsiteConfiguration)),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
/// Get bucket notification
#[instrument(
level = "debug",
@@ -528,6 +622,27 @@ impl S3 for FS {
usecase.execute_put_bucket_acl(req).await
}
async fn put_bucket_accelerate_configuration(
&self,
req: S3Request<PutBucketAccelerateConfigurationInput>,
) -> S3Result<S3Response<PutBucketAccelerateConfigurationOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let accelerate_config = serialize(&req.input.accelerate_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_ACCELERATE_CONFIG, accelerate_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketAccelerateConfigurationOutput::default()))
}
#[instrument(level = "debug", skip(self))]
async fn put_bucket_cors(&self, req: S3Request<PutBucketCorsInput>) -> S3Result<S3Response<PutBucketCorsOutput>> {
let usecase = DefaultBucketUsecase::from_global();
@@ -543,7 +658,14 @@ impl S3 for FS {
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
Err(s3_error!(NotImplemented, "GetBucketLogging is not implemented yet"))
match metadata_sys::get_logging_config(&req.input.bucket).await {
Ok((logging, _)) => Ok(S3Response::new(GetBucketLoggingOutput {
logging_enabled: logging.logging_enabled,
})),
Err(StorageError::ConfigNotFound) => Ok(S3Response::new(GetBucketLoggingOutput::default())),
Err(err) => Err(crate::error::ApiError::from(err).into()),
}
}
async fn put_bucket_logging(&self, req: S3Request<PutBucketLoggingInput>) -> S3Result<S3Response<PutBucketLoggingOutput>> {
@@ -555,7 +677,14 @@ impl S3 for FS {
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
Err(s3_error!(NotImplemented, "PutBucketLogging is not implemented yet"))
let logging_config = serialize(&req.input.bucket_logging_status)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_LOGGING_CONFIG, logging_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketLoggingOutput::default()))
}
async fn put_bucket_encryption(
@@ -596,6 +725,27 @@ impl S3 for FS {
usecase.execute_put_bucket_replication(req).await
}
async fn put_bucket_request_payment(
&self,
req: S3Request<PutBucketRequestPaymentInput>,
) -> S3Result<S3Response<PutBucketRequestPaymentOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let payment_config = serialize(&req.input.request_payment_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_REQUEST_PAYMENT_CONFIG, payment_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketRequestPaymentOutput::default()))
}
#[instrument(level = "debug", skip(self))]
async fn put_public_access_block(
&self,
@@ -620,6 +770,24 @@ impl S3 for FS {
usecase.execute_put_bucket_versioning(req).await
}
async fn put_bucket_website(&self, req: S3Request<PutBucketWebsiteInput>) -> S3Result<S3Response<PutBucketWebsiteOutput>> {
let Some(store) = new_object_layer_fn() else {
return Err(s3_error!(InternalError, "Not init"));
};
store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.map_err(crate::error::ApiError::from)?;
let website_config = serialize(&req.input.website_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
metadata_sys::update(&req.input.bucket, BUCKET_WEBSITE_CONFIG, website_config)
.await
.map_err(crate::error::ApiError::from)?;
Ok(S3Response::new(PutBucketWebsiteOutput::default()))
}
#[instrument(level = "debug", skip(self, req))]
async fn put_object(&self, req: S3Request<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
let usecase = DefaultObjectUsecase::from_global();
+1 -1
View File
@@ -334,7 +334,7 @@ pub fn extract_metadata_from_mime(headers: &HeaderMap<HeaderValue>, metadata: &m
/// request-side transfer encoding for SigV4 streaming and must not be stored or returned.
/// If the only value is "aws-chunked", returns None (do not persist). Otherwise returns
/// the value with "aws-chunked" stripped, or None if nothing remains.
fn normalize_content_encoding_for_storage(value: &str) -> Option<String> {
pub(crate) fn normalize_content_encoding_for_storage(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use rustfs_notify::notification_system;
impl NodeService {
pub(super) async fn handle_get_live_events(
&self,
request: Request<GetLiveEventsRequest>,
) -> Result<Response<GetLiveEventsResponse>, Status> {
let request = request.into_inner();
let limit = usize::try_from(request.limit).unwrap_or(0).clamp(1, 256);
let Some(system) = notification_system() else {
return Ok(Response::new(GetLiveEventsResponse {
success: true,
events: Bytes::new(),
next_sequence: request.after_sequence,
truncated: false,
error_info: None,
}));
};
let batch = system.recent_live_events_since(request.after_sequence, limit).await;
let events = batch.events.into_iter().map(|event| (*event).clone()).collect::<Vec<_>>();
let payload = match serde_json::to_vec(&events) {
Ok(payload) => payload,
Err(err) => {
return Ok(Response::new(GetLiveEventsResponse {
success: false,
events: Bytes::new(),
next_sequence: request.after_sequence,
truncated: false,
error_info: Some(format!("failed to serialize live events: {err}")),
}));
}
};
Ok(Response::new(GetLiveEventsResponse {
success: true,
events: payload.into(),
next_sequence: batch.next_sequence,
truncated: batch.truncated,
error_info: None,
}))
}
}
+6
View File
@@ -56,6 +56,8 @@ type ResponseStream<T> = Pin<Box<dyn Stream<Item = Result<T, Status>> + Send>>;
mod bucket;
#[path = "disk.rs"]
mod disk;
#[path = "event.rs"]
mod event;
#[path = "health.rs"]
mod health;
#[path = "lock.rs"]
@@ -429,6 +431,10 @@ impl Node for NodeService {
self.handle_get_metrics(request).await
}
async fn get_live_events(&self, request: Request<GetLiveEventsRequest>) -> Result<Response<GetLiveEventsResponse>, Status> {
self.handle_get_live_events(request).await
}
async fn get_proc_info(&self, _request: Request<GetProcInfoRequest>) -> Result<Response<GetProcInfoResponse>, Status> {
self.handle_get_proc_info(_request).await
}
+161 -16
View File
@@ -96,6 +96,8 @@ use std::sync::{Arc, OnceLock};
use tokio::io::AsyncRead;
use tracing::{debug, error};
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
use crate::error::ApiError;
use crate::storage::readers::InMemoryAsyncReader;
use rustfs_ecstore::bucket::metadata_sys;
@@ -159,6 +161,15 @@ async fn prepare_sse_configuration(
server_side_encryption: Option<ServerSideEncryption>,
ssekms_key_id: Option<SSEKMSKeyId>,
) -> Result<Option<SseConfiguration>, ApiError> {
if let Some(server_side_encryption) = server_side_encryption.clone()
&& server_side_encryption.as_str() == ServerSideEncryption::AES256
{
return Ok(Some(SseConfiguration {
effective_sse: server_side_encryption,
effective_kms_key_id: None,
}));
}
if let Some(server_side_encryption) = server_side_encryption.clone()
&& let Some(ssekms_key_id) = ssekms_key_id
{
@@ -192,7 +203,7 @@ async fn prepare_sse_configuration(
debug!("effective_sse={:?} (original={:?})", effective_sse, server_side_encryption);
let effective_kms_key_id = ssekms_key_id.or_else(|| {
let effective_kms_key_id = resolve_effective_kms_key_id(effective_sse.as_ref(), ssekms_key_id, || {
bucket_sse_config.rules.first().and_then(|rule| {
rule.apply_server_side_encryption_by_default
.as_ref()
@@ -226,6 +237,21 @@ async fn prepare_sse_configuration(
}
}
fn resolve_effective_kms_key_id<F>(
effective_sse: Option<&ServerSideEncryption>,
requested_kms_key_id: Option<SSEKMSKeyId>,
bucket_default_kms_key_id: F,
) -> Option<SSEKMSKeyId>
where
F: FnOnce() -> Option<SSEKMSKeyId>,
{
if effective_sse.is_none_or(|sse| sse.as_str() != ServerSideEncryption::AWS_KMS) {
return requested_kms_key_id;
}
requested_kms_key_id.or_else(bucket_default_kms_key_id)
}
#[derive(Debug, Clone)]
pub enum SseTypeV2 {
SseS3(ServerSideEncryption),
@@ -524,6 +550,8 @@ pub struct DecryptionRequest<'a> {
pub part_number: Option<usize>,
/// Parts information for multipart objects
pub parts: &'a [ObjectPartInfo],
/// Object-level ETag, used to distinguish multipart objects from single-part objects.
pub etag: Option<&'a str>,
}
/// Unified encryption material returned by `apply_encryption()`
@@ -568,6 +596,14 @@ pub struct DecryptionMaterial {
pub parts: Vec<ObjectPartInfo>,
}
fn is_multipart_object(etag: Option<&str>, parts: &[ObjectPartInfo]) -> bool {
if parts.len() > 1 {
return true;
}
etag.map(|etag| etag.trim_matches('"').len() != 32).unwrap_or(false)
}
/// Type of encryption used
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SSEType {
@@ -815,7 +851,7 @@ pub async fn sse_prepare_encryption(request: PrepareEncryptionRequest<'_>) -> Re
/// }
/// ```
pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<DecryptionMaterial>, ApiError> {
let is_multipart = request.parts.len() > 1;
let is_multipart = is_multipart_object(request.etag, request.parts);
// Check for SSE-C encryption
if request
@@ -1021,7 +1057,7 @@ async fn apply_managed_encryption_material(
context = context.with_size(content_size as u64);
}
// Determine KMS key ID to use
// Determine KMS key ID to use for internal key wrapping.
let mut kms_key_candidate = kms_key_id.clone().map(|s| s.to_string());
if kms_key_candidate.is_none() {
// Try to get default key from KMS service (if available)
@@ -1030,11 +1066,17 @@ async fn apply_managed_encryption_material(
}
}
let kms_key_to_use = kms_key_candidate.clone().ok_or_else(|| {
ApiError::from(StorageError::other(
"No KMS key available for managed server-side encryption (required for SSE-KMS)",
))
})?;
let kms_key_to_use = match (encryption_type, kms_key_candidate.clone()) {
(SSEType::SseS3, Some(kms_key_id)) => kms_key_id,
(SSEType::SseS3, None) => "default".to_string(),
(SSEType::SseKms, Some(kms_key_id)) => kms_key_id,
(SSEType::SseKms, None) => {
return Err(ApiError::from(StorageError::other(
"No KMS key available for managed server-side encryption (required for SSE-KMS)",
)));
}
_ => unreachable!("managed SSE branch only supports SSE-S3 or SSE-KMS"),
};
let provider = get_sse_dek_provider().await?;
@@ -1074,7 +1116,7 @@ async fn apply_managed_encryption_material(
(data_key, encrypted_data_key)
};
let algorithm = DEFAULT_SSE_ALGORITHM.to_string();
let algorithm = server_side_encryption.as_str().to_string();
let encryption_metadata = EncryptionMetadata {
algorithm: algorithm.clone(),
@@ -1103,13 +1145,15 @@ async fn apply_managed_encryption_material(
metadata.insert("x-rustfs-encryption-iv".to_string(), BASE64_STANDARD.encode(&encryption_metadata.iv));
metadata.insert("x-rustfs-encryption-algorithm".to_string(), encryption_metadata.algorithm.clone());
metadata.insert("x-amz-server-side-encryption".to_string(), server_side_encryption.as_str().to_string());
// if kms_key is changed, we need to update the metadata
if kms_key_id.is_none() {
metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), kms_key_to_use.clone());
}
}
if matches!(encryption_type, SSEType::SseKms) {
metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), kms_key_to_use.clone());
} else {
metadata.remove("x-amz-server-side-encryption-aws-kms-key-id");
}
metadata.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), kms_key_to_use.clone());
metadata.insert(
"x-rustfs-encryption-original-size".to_string(),
encryption_metadata.original_size.to_string(),
@@ -1118,7 +1162,7 @@ async fn apply_managed_encryption_material(
Ok(EncryptionMaterial {
sse_type: encryption_type,
server_side_encryption,
kms_key_id: Some(kms_key_to_use),
kms_key_id: matches!(encryption_type, SSEType::SseKms).then_some(kms_key_to_use),
algorithm,
key_bytes: data_key.plaintext_key,
@@ -1182,7 +1226,8 @@ async fn apply_managed_decryption_material(
// Extract KMS key ID from metadata (optional, used for provider context)
let kms_key_id = metadata
.get("x-amz-server-side-encryption-aws-kms-key-id")
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned()
.unwrap_or_else(|| "default".to_string());
@@ -2179,6 +2224,80 @@ mod tests {
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
}
#[test]
fn test_resolve_effective_kms_key_id_ignores_bucket_default_for_explicit_sse_s3() {
let effective_sse = ServerSideEncryption::from_static(ServerSideEncryption::AES256);
let kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), None, || Some("bucket-default".to_string()));
assert_eq!(kms_key_id, None);
}
#[test]
fn test_resolve_effective_kms_key_id_uses_bucket_default_for_sse_kms() {
let effective_sse = ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS);
let kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), None, || Some("bucket-default".to_string()));
assert_eq!(kms_key_id.as_deref(), Some("bucket-default"));
}
#[tokio::test]
async fn test_sse_encryption_persists_aws_kms_header_for_kms_objects() {
let request = EncryptionRequest {
bucket: "test-bucket",
key: "test-key",
server_side_encryption: Some("aws:kms".to_string().into()),
ssekms_key_id: Some("test-key".to_string()),
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
content_size: 1024,
part_number: None,
part_key: None,
part_nonce: None,
};
let material = sse_encryption(request).await.expect("kms encryption should succeed");
let metadata = material.expect("managed kms encryption should return material").metadata;
assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("aws:kms"));
assert_eq!(
metadata
.get("x-amz-server-side-encryption-aws-kms-key-id")
.map(String::as_str),
Some("test-key")
);
}
#[tokio::test]
async fn test_sse_encryption_omits_kms_header_for_sse_s3_objects() {
let request = EncryptionRequest {
bucket: "test-bucket",
key: "test-key",
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
ssekms_key_id: None,
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
content_size: 1024,
part_number: None,
part_key: None,
part_nonce: None,
};
let material = sse_encryption(request).await.expect("sse-s3 encryption should succeed");
let material = material.expect("managed sse-s3 encryption should return material");
assert_eq!(material.kms_key_id, None);
assert_eq!(material.metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256"));
assert!(!material.metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id"));
assert_eq!(
material.metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str),
Some("default")
);
}
#[test]
fn test_strip_managed_encryption_metadata() {
let mut metadata = HashMap::new();
@@ -2193,6 +2312,32 @@ mod tests {
assert!(metadata.contains_key("content-type"));
}
#[test]
fn test_is_multipart_object_treats_single_part_multipart_etag_as_multipart() {
let metadata = HashMap::from([("etag".to_string(), "0123456789abcdef0123456789abcdef-1".to_string())]);
let parts = vec![ObjectPartInfo {
number: 1,
size: 128,
actual_size: 64,
..Default::default()
}];
assert!(is_multipart_object(metadata.get("etag").map(String::as_str), &parts));
}
#[test]
fn test_is_multipart_object_keeps_regular_single_part_object_as_non_multipart() {
let metadata = HashMap::from([("etag".to_string(), "0123456789abcdef0123456789abcdef".to_string())]);
let parts = vec![ObjectPartInfo {
number: 1,
size: 128,
actual_size: 64,
..Default::default()
}];
assert!(!is_multipart_object(metadata.get("etag").map(String::as_str), &parts));
}
#[test]
fn test_verify_ssec_key_match_success() {
let md5 = "test_md5".to_string();
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""Send one S3 request to two endpoints and summarize response differences."""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import hashlib
import hmac
import http.client
import json
import os
import pathlib
import ssl
import sys
import urllib.parse
from dataclasses import dataclass
from difflib import unified_diff
from typing import Iterable
DEFAULT_IGNORE_HEADERS = {
"date",
"server",
"x-amz-id-2",
"x-amz-request-id",
"x-rustfs-deployment-id",
}
@dataclass
class Endpoint:
label: str
url: str
@dataclass
class SignedRequest:
method: str
endpoint: Endpoint
path_and_query: str
headers: list[tuple[str, str]]
body: bytes
@dataclass
class ResponseSnapshot:
label: str
url: str
status: int
reason: str
headers: list[tuple[str, str]]
body: bytes
def normalized_headers(self, ignored: set[str]) -> dict[str, list[str]]:
values: dict[str, list[str]] = {}
for name, value in self.headers:
key = name.lower()
if key in ignored:
continue
values.setdefault(key, []).append(value)
for key in values:
values[key].sort()
return dict(sorted(values.items()))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--left-url", required=True, help="Reference endpoint base URL, for example http://127.0.0.1:9000")
parser.add_argument("--right-url", required=True, help="Candidate endpoint base URL, for example http://127.0.0.1:9001")
parser.add_argument("--left-label", default="reference", help="Label used in reports for the left endpoint")
parser.add_argument("--right-label", default="candidate", help="Label used in reports for the right endpoint")
parser.add_argument("--method", default="GET", help="HTTP method")
parser.add_argument("--path", required=True, help="Absolute request path with optional query, for example /bucket/object?versionId=1")
parser.add_argument("--header", action="append", default=[], help="Extra header in 'Name: Value' form")
parser.add_argument("--body-file", help="Read request body from file")
parser.add_argument("--body", default="", help="Inline request body string")
parser.add_argument("--content-type", help="Convenience setter for Content-Type")
parser.add_argument("--region", default=os.getenv("AWS_REGION", "us-east-1"), help="Signing region")
parser.add_argument("--service", default="s3", help="Signing service name")
parser.add_argument("--access-key", default=os.getenv("AWS_ACCESS_KEY_ID", ""), help="Access key for SigV4 signing")
parser.add_argument("--secret-key", default=os.getenv("AWS_SECRET_ACCESS_KEY", ""), help="Secret key for SigV4 signing")
parser.add_argument("--session-token", default=os.getenv("AWS_SESSION_TOKEN", ""), help="Optional session token")
parser.add_argument("--unsigned", action="store_true", help="Send request without SigV4 signing")
parser.add_argument("--timeout", type=float, default=30.0, help="Per-request timeout in seconds")
parser.add_argument("--insecure", action="store_true", help="Disable TLS certificate verification")
parser.add_argument(
"--ignore-header",
action="append",
default=[],
help="Response header name to ignore during comparison. Can be provided multiple times.",
)
parser.add_argument("--output-dir", default="artifacts/s3-compare/latest", help="Directory for snapshots and summary")
return parser.parse_args()
def parse_header(raw: str) -> tuple[str, str]:
name, sep, value = raw.partition(":")
if not sep:
raise ValueError(f"invalid header {raw!r}, expected 'Name: Value'")
name = name.strip()
value = value.strip()
if not name:
raise ValueError(f"invalid header {raw!r}, empty name")
return name, value
def load_body(args: argparse.Namespace) -> bytes:
if args.body_file:
return pathlib.Path(args.body_file).read_bytes()
return args.body.encode()
def payload_hash(body: bytes) -> str:
return hashlib.sha256(body).hexdigest()
def sign(key: bytes, value: str) -> bytes:
return hmac.new(key, value.encode(), hashlib.sha256).digest()
def derive_signing_key(secret_key: str, date_stamp: str, region: str, service: str) -> bytes:
key_date = sign(("AWS4" + secret_key).encode(), date_stamp)
key_region = sign(key_date, region)
key_service = sign(key_region, service)
return sign(key_service, "aws4_request")
def canonical_query(query: str) -> str:
pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
encoded = [
(
urllib.parse.quote(key, safe="-_.~"),
urllib.parse.quote(value, safe="-_.~"),
)
for key, value in pairs
]
encoded.sort()
return "&".join(f"{key}={value}" for key, value in encoded)
def canonical_uri(path: str) -> str:
segments = path.split("/")
return "/".join(urllib.parse.quote(segment, safe="-_.~/") for segment in segments) or "/"
def build_signed_request(
endpoint: Endpoint,
method: str,
raw_path: str,
headers: list[tuple[str, str]],
body: bytes,
args: argparse.Namespace,
) -> SignedRequest:
parsed = urllib.parse.urlsplit(endpoint.url)
if not parsed.scheme or not parsed.netloc:
raise ValueError(f"invalid endpoint URL: {endpoint.url}")
if not raw_path.startswith("/"):
raise ValueError("--path must start with '/'")
path, _, query = raw_path.partition("?")
amz_date = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
date_stamp = amz_date[:8]
request_headers: list[tuple[str, str]] = []
for name, value in headers:
request_headers.append((name, value))
if not any(name.lower() == "host" for name, _ in request_headers):
request_headers.append(("Host", parsed.netloc))
if not any(name.lower() == "x-amz-content-sha256" for name, _ in request_headers):
request_headers.append(("x-amz-content-sha256", payload_hash(body)))
if not any(name.lower() == "x-amz-date" for name, _ in request_headers):
request_headers.append(("x-amz-date", amz_date))
if args.session_token and not any(name.lower() == "x-amz-security-token" for name, _ in request_headers):
request_headers.append(("x-amz-security-token", args.session_token))
if args.unsigned:
return SignedRequest(method=method, endpoint=endpoint, path_and_query=raw_path, headers=request_headers, body=body)
if not args.access_key or not args.secret_key:
raise ValueError("SigV4 signing requires --access-key and --secret-key, or set --unsigned")
normalized = [(name.lower().strip(), " ".join(value.strip().split())) for name, value in request_headers]
normalized.sort()
canonical_headers = "".join(f"{name}:{value}\n" for name, value in normalized)
signed_headers = ";".join(name for name, _ in normalized)
canonical_request = "\n".join(
[
method,
canonical_uri(path),
canonical_query(query),
canonical_headers,
signed_headers,
payload_hash(body),
]
)
scope = f"{date_stamp}/{args.region}/{args.service}/aws4_request"
string_to_sign = "\n".join(
[
"AWS4-HMAC-SHA256",
amz_date,
scope,
hashlib.sha256(canonical_request.encode()).hexdigest(),
]
)
signature = hmac.new(
derive_signing_key(args.secret_key, date_stamp, args.region, args.service),
string_to_sign.encode(),
hashlib.sha256,
).hexdigest()
authorization = (
"AWS4-HMAC-SHA256 "
f"Credential={args.access_key}/{scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
request_headers.append(("Authorization", authorization))
return SignedRequest(method=method, endpoint=endpoint, path_and_query=raw_path, headers=request_headers, body=body)
def send_request(request: SignedRequest, timeout: float, insecure: bool) -> ResponseSnapshot:
parsed = urllib.parse.urlsplit(request.endpoint.url)
if parsed.scheme == "https":
context = ssl.create_default_context()
if insecure:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
conn: http.client.HTTPConnection = http.client.HTTPSConnection(parsed.hostname, parsed.port or 443, timeout=timeout, context=context)
elif parsed.scheme == "http":
conn = http.client.HTTPConnection(parsed.hostname, parsed.port or 80, timeout=timeout)
else:
raise ValueError(f"unsupported URL scheme: {parsed.scheme}")
conn.request(request.method, request.path_and_query, body=request.body, headers=dict(request.headers))
response = conn.getresponse()
body = response.read()
headers = response.getheaders()
conn.close()
return ResponseSnapshot(
label=request.endpoint.label,
url=request.endpoint.url,
status=response.status,
reason=response.reason,
headers=headers,
body=body,
)
def body_as_text(body: bytes) -> str | None:
try:
return body.decode("utf-8")
except UnicodeDecodeError:
return None
def summarize_diff(left: ResponseSnapshot, right: ResponseSnapshot, ignored_headers: set[str]) -> dict:
header_diff = {
"left_only": {},
"right_only": {},
"different": {},
}
left_headers = left.normalized_headers(ignored_headers)
right_headers = right.normalized_headers(ignored_headers)
left_keys = set(left_headers)
right_keys = set(right_headers)
for key in sorted(left_keys - right_keys):
header_diff["left_only"][key] = left_headers[key]
for key in sorted(right_keys - left_keys):
header_diff["right_only"][key] = right_headers[key]
for key in sorted(left_keys & right_keys):
if left_headers[key] != right_headers[key]:
header_diff["different"][key] = {
left.label: left_headers[key],
right.label: right_headers[key],
}
left_text = body_as_text(left.body)
right_text = body_as_text(right.body)
if left_text is not None and right_text is not None:
body_diff = {
"kind": "text",
"equal": left_text == right_text,
"unified_diff": list(
unified_diff(
left_text.splitlines(),
right_text.splitlines(),
fromfile=left.label,
tofile=right.label,
lineterm="",
)
),
}
else:
body_diff = {
"kind": "binary",
"equal": left.body == right.body,
left.label: {
"size": len(left.body),
"sha256": hashlib.sha256(left.body).hexdigest(),
},
right.label: {
"size": len(right.body),
"sha256": hashlib.sha256(right.body).hexdigest(),
},
}
return {
"status_equal": left.status == right.status,
"status": {
left.label: {"code": left.status, "reason": left.reason},
right.label: {"code": right.status, "reason": right.reason},
},
"headers_equal": not any(header_diff.values()),
"header_diff": header_diff,
"body_equal": body_diff["equal"],
"body_diff": body_diff,
}
def write_snapshot(output_dir: pathlib.Path, response: ResponseSnapshot) -> dict:
endpoint_dir = output_dir / response.label
endpoint_dir.mkdir(parents=True, exist_ok=True)
body_file = endpoint_dir / "body.bin"
body_file.write_bytes(response.body)
headers_file = endpoint_dir / "headers.json"
headers_file.write_text(json.dumps(response.headers, indent=2, ensure_ascii=False) + "\n")
summary = {
"label": response.label,
"url": response.url,
"status": response.status,
"reason": response.reason,
"headers_file": str(headers_file),
"body_file": str(body_file),
"body_sha256": hashlib.sha256(response.body).hexdigest(),
"body_base64_preview": base64.b64encode(response.body[:96]).decode(),
}
(endpoint_dir / "response.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n")
return summary
def print_report(diff: dict, left: ResponseSnapshot, right: ResponseSnapshot) -> None:
print(f"Compared {left.label} <-> {right.label}")
print(f"Status: {left.status} vs {right.status}")
print(f"Headers equal: {diff['headers_equal']}")
print(f"Body equal: {diff['body_equal']}")
header_diff = diff["header_diff"]
if header_diff["left_only"] or header_diff["right_only"] or header_diff["different"]:
print("Header differences detected:")
if header_diff["left_only"]:
print(f" left only: {sorted(header_diff['left_only'])}")
if header_diff["right_only"]:
print(f" right only: {sorted(header_diff['right_only'])}")
if header_diff["different"]:
print(f" changed: {sorted(header_diff['different'])}")
body_diff = diff["body_diff"]
if body_diff["kind"] == "text" and not body_diff["equal"]:
preview = body_diff["unified_diff"][:40]
if preview:
print("Body diff preview:")
for line in preview:
print(line)
def main() -> int:
args = parse_args()
try:
extra_headers = [parse_header(raw) for raw in args.header]
if args.content_type and not any(name.lower() == "content-type" for name, _ in extra_headers):
extra_headers.append(("Content-Type", args.content_type))
body = load_body(args)
output_dir = pathlib.Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
left_endpoint = Endpoint(label=args.left_label, url=args.left_url.rstrip("/"))
right_endpoint = Endpoint(label=args.right_label, url=args.right_url.rstrip("/"))
method = args.method.upper()
requests = [
build_signed_request(left_endpoint, method, args.path, extra_headers, body, args),
build_signed_request(right_endpoint, method, args.path, extra_headers, body, args),
]
responses = [send_request(request, args.timeout, args.insecure) for request in requests]
left, right = responses
ignored_headers = {name.lower() for name in DEFAULT_IGNORE_HEADERS}
ignored_headers.update(name.lower() for name in args.ignore_header)
report = {
"request": {
"method": method,
"path": args.path,
"headers": requests[0].headers,
"body_sha256": payload_hash(body),
"body_size": len(body),
"signed": not args.unsigned,
},
"responses": [write_snapshot(output_dir, response) for response in responses],
}
report["diff"] = summarize_diff(left, right, ignored_headers)
(output_dir / "summary.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
print_report(report["diff"], left, right)
return 0 if report["diff"]["status_equal"] and report["diff"]["headers_equal"] and report["diff"]["body_equal"] else 1
except Exception as exc: # noqa: BLE001
print(f"compare_dual_targets.py failed: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())