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