mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 23:47:28 +00:00
refactor(sse): decouple ecstore and harden KMS lifecycle (#5435)
* refactor(sse): decouple encryption from ecstore * feat(kms): enhance KMS service manager with runtime state and persistence support * feat(kms): add local key export functionality for SSE-S3 migration tests * fix(kms): keep local key export narrowly scoped * fix(sse): validate copy source customer algorithm --------- Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
This commit is contained in:
Generated
-3
@@ -9099,7 +9099,6 @@ dependencies = [
|
|||||||
name = "rustfs-ecstore"
|
name = "rustfs-ecstore"
|
||||||
version = "1.0.0-beta.12"
|
version = "1.0.0-beta.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
@@ -9115,7 +9114,6 @@ dependencies = [
|
|||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bytesize",
|
"bytesize",
|
||||||
"chacha20poly1305",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
"enumset",
|
"enumset",
|
||||||
@@ -9169,7 +9167,6 @@ dependencies = [
|
|||||||
"rustfs-erasure-codec",
|
"rustfs-erasure-codec",
|
||||||
"rustfs-filemeta",
|
"rustfs-filemeta",
|
||||||
"rustfs-io-metrics",
|
"rustfs-io-metrics",
|
||||||
"rustfs-kms",
|
|
||||||
"rustfs-lifecycle",
|
"rustfs-lifecycle",
|
||||||
"rustfs-lock",
|
"rustfs-lock",
|
||||||
"rustfs-madmin",
|
"rustfs-madmin",
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ rustfs-policy.workspace = true
|
|||||||
rustfs-protos.workspace = true
|
rustfs-protos.workspace = true
|
||||||
rustfs-replication.workspace = true
|
rustfs-replication.workspace = true
|
||||||
rustfs-lifecycle.workspace = true
|
rustfs-lifecycle.workspace = true
|
||||||
rustfs-kms.workspace = true
|
|
||||||
rustfs-s3-types = { workspace = true }
|
rustfs-s3-types = { workspace = true }
|
||||||
rustfs-data-usage.workspace = true
|
rustfs-data-usage.workspace = true
|
||||||
rustfs-object-capacity.workspace = true
|
rustfs-object-capacity.workspace = true
|
||||||
@@ -125,8 +124,6 @@ libc.workspace = true
|
|||||||
rustix = { workspace = true, features = ["process", "fs"] }
|
rustix = { workspace = true, features = ["process", "fs"] }
|
||||||
rustfs-madmin.workspace = true
|
rustfs-madmin.workspace = true
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
aes-gcm = { workspace = true, features = ["rand_core"] }
|
|
||||||
chacha20poly1305.workspace = true
|
|
||||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
smallvec = { workspace = true, features = ["serde"] }
|
smallvec = { workspace = true, features = ["serde"] }
|
||||||
|
|||||||
@@ -391,10 +391,12 @@ pub mod notification {
|
|||||||
|
|
||||||
pub mod object {
|
pub mod object {
|
||||||
pub use crate::object_api::{
|
pub use crate::object_api::{
|
||||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
|
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||||
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
|
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, ObjectEncryptionResolver, ObjectInfo,
|
||||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode,
|
||||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
||||||
|
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
||||||
|
unregister_object_mutation_hook,
|
||||||
};
|
};
|
||||||
pub use crate::store::PreparedGetObjectReader;
|
pub use crate::store::PreparedGetObjectReader;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,16 +46,6 @@ lazy_static! {
|
|||||||
m.insert("x-amz-replication-status".to_string(), true);
|
m.insert("x-amz-replication-status".to_string(), true);
|
||||||
m
|
m
|
||||||
};
|
};
|
||||||
static ref SSE_HEADERS: HashMap<String, bool> = {
|
|
||||||
let mut m = HashMap::new();
|
|
||||||
m.insert("x-amz-server-side-encryption".to_string(), true);
|
|
||||||
m.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), true);
|
|
||||||
m.insert("x-amz-server-side-encryption-context".to_string(), true);
|
|
||||||
m.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), true);
|
|
||||||
m.insert("x-amz-server-side-encryption-customer-key".to_string(), true);
|
|
||||||
m.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), true);
|
|
||||||
m
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_standard_query_value(qs_key: &str) -> bool {
|
pub fn is_standard_query_value(qs_key: &str) -> bool {
|
||||||
@@ -70,16 +60,12 @@ pub fn is_standard_header(header_key: &str) -> bool {
|
|||||||
*SUPPORTED_HEADERS.get(&header_key.to_lowercase()).unwrap_or(&false)
|
*SUPPORTED_HEADERS.get(&header_key.to_lowercase()).unwrap_or(&false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_sse_header(header_key: &str) -> bool {
|
|
||||||
*SSE_HEADERS.get(&header_key.to_lowercase()).unwrap_or(&false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_amz_header(header_key: &str) -> bool {
|
pub fn is_amz_header(header_key: &str) -> bool {
|
||||||
let key = header_key.to_lowercase();
|
let key = header_key.to_lowercase();
|
||||||
key.starts_with("x-amz-meta-")
|
key.starts_with("x-amz-meta-")
|
||||||
|| key.starts_with("x-amz-grant-")
|
|| key.starts_with("x-amz-grant-")
|
||||||
|| key == "x-amz-acl"
|
|| key == "x-amz-acl"
|
||||||
|| is_sse_header(header_key)
|
|| rustfs_utils::http::is_sse_header(header_key)
|
||||||
|| key.starts_with("x-amz-checksum-")
|
|| key.starts_with("x-amz-checksum-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// 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 async_trait::async_trait;
|
||||||
|
use http::{HeaderMap, HeaderValue};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReadEncryptionMode {
|
||||||
|
Direct { base_nonce: [u8; 12] },
|
||||||
|
Object,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReadEncryptionMaterial {
|
||||||
|
pub key_bytes: [u8; 32],
|
||||||
|
pub mode: ReadEncryptionMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EncryptionResolutionErrorKind {
|
||||||
|
InvalidRequest,
|
||||||
|
InvalidMetadata,
|
||||||
|
ServiceUnavailable,
|
||||||
|
DecryptionFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EncryptionResolutionError {
|
||||||
|
kind: EncryptionResolutionErrorKind,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncryptionResolutionError {
|
||||||
|
pub fn new(kind: EncryptionResolutionErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> EncryptionResolutionErrorKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for EncryptionResolutionError {
|
||||||
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for EncryptionResolutionError {}
|
||||||
|
|
||||||
|
pub struct ReadEncryptionRequest<'a> {
|
||||||
|
pub bucket: &'a str,
|
||||||
|
pub object: &'a str,
|
||||||
|
pub metadata: &'a HashMap<String, String>,
|
||||||
|
pub headers: &'a HeaderMap<HeaderValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ObjectEncryptionResolver: Send + Sync {
|
||||||
|
async fn resolve_read_material(
|
||||||
|
&self,
|
||||||
|
request: ReadEncryptionRequest<'_>,
|
||||||
|
) -> Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError>;
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ pub(crate) fn legacy_encrypted_range_seek_enabled() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod body_cache_hook;
|
mod body_cache_hook;
|
||||||
|
mod encryption;
|
||||||
mod hook_slot;
|
mod hook_slot;
|
||||||
mod object_mutation_hook;
|
mod object_mutation_hook;
|
||||||
mod readers;
|
mod readers;
|
||||||
@@ -98,6 +99,10 @@ pub use body_cache_hook::{
|
|||||||
pub(crate) use body_cache_hook::{
|
pub(crate) use body_cache_hook::{
|
||||||
get_object_body_cache_hook, get_object_body_cache_hook_suppressed, without_get_object_body_cache_hook,
|
get_object_body_cache_hook, get_object_body_cache_hook_suppressed, without_get_object_body_cache_hook,
|
||||||
};
|
};
|
||||||
|
pub use encryption::{
|
||||||
|
EncryptionResolutionError, EncryptionResolutionErrorKind, ObjectEncryptionResolver, ReadEncryptionMaterial,
|
||||||
|
ReadEncryptionMode, ReadEncryptionRequest,
|
||||||
|
};
|
||||||
pub(crate) use object_mutation_hook::notify_object_mutation;
|
pub(crate) use object_mutation_hook::notify_object_mutation;
|
||||||
pub use object_mutation_hook::{ObjectMutationHook, register_object_mutation_hook, unregister_object_mutation_hook};
|
pub use object_mutation_hook::{ObjectMutationHook, register_object_mutation_hook, unregister_object_mutation_hook};
|
||||||
pub use readers::*;
|
pub use readers::*;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -273,29 +273,9 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_encrypted(&self) -> bool {
|
pub fn is_encrypted(&self) -> bool {
|
||||||
// Corresponding to the logic in rustfs/src/sse.rs/encryption_material_to_metadata function
|
self.user_defined
|
||||||
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
.keys()
|
||||||
|
.any(|key| rustfs_utils::http::is_object_encryption_marker(key))
|
||||||
self.user_defined.keys().any(|key| {
|
|
||||||
let lower = key.to_ascii_lowercase();
|
|
||||||
lower.starts_with("x-minio-encryption-")
|
|
||||||
|| lower.starts_with("x-minio-internal-server-side-encryption-")
|
|
||||||
|| matches!(
|
|
||||||
lower.as_str(),
|
|
||||||
"x-minio-internal-encrypted-multipart"
|
|
||||||
| "x-rustfs-encryption-key"
|
|
||||||
| "x-rustfs-encryption-algorithm"
|
|
||||||
| "x-rustfs-encryption-iv"
|
|
||||||
| "x-rustfs-encryption-key-id"
|
|
||||||
| "x-rustfs-encryption-context"
|
|
||||||
| "x-rustfs-encryption-tag"
|
|
||||||
| "x-amz-server-side-encryption-aws-kms-key-id"
|
|
||||||
| SSEC_ALGORITHM_HEADER
|
|
||||||
| SSEC_KEY_HEADER
|
|
||||||
| SSEC_KEY_MD5_HEADER
|
|
||||||
| "x-amz-server-side-encryption"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum inline size for non-versioned objects (128 KiB).
|
/// Maximum inline size for non-versioned objects (128 KiB).
|
||||||
@@ -339,26 +319,7 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn encryption_original_size(&self) -> std::io::Result<Option<i64>> {
|
pub fn encryption_original_size(&self) -> std::io::Result<Option<i64>> {
|
||||||
let actual_size = rustfs_utils::http::get_str(&self.user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE);
|
rustfs_utils::http::get_object_encryption_original_size(&self.user_defined)
|
||||||
if let Some(size_str) = self
|
|
||||||
.user_defined
|
|
||||||
.get("x-rustfs-encryption-original-size")
|
|
||||||
.map(String::as_str)
|
|
||||||
.or_else(|| {
|
|
||||||
self.user_defined
|
|
||||||
.get("x-amz-server-side-encryption-customer-original-size")
|
|
||||||
.map(String::as_str)
|
|
||||||
})
|
|
||||||
.or(actual_size.as_deref())
|
|
||||||
&& !size_str.is_empty()
|
|
||||||
{
|
|
||||||
let size = size_str
|
|
||||||
.parse::<i64>()
|
|
||||||
.map_err(|e| std::io::Error::other(format!("Failed to parse encryption original size: {e}")))?;
|
|
||||||
return Ok(Some(size));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
||||||
@@ -388,9 +349,6 @@ impl ObjectInfo {
|
|||||||
return Ok(actual_size);
|
return Ok(actual_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if object is encrypted
|
|
||||||
// Managed SSE stores original size in x-rustfs-encryption-original-size metadata
|
|
||||||
// SSE-C stores original size in x-amz-server-side-encryption-customer-original-size
|
|
||||||
if let Some(size) = self.encryption_original_size()? {
|
if let Some(size) = self.encryption_original_size()? {
|
||||||
return Ok(size);
|
return Ok(size);
|
||||||
}
|
}
|
||||||
@@ -881,6 +839,19 @@ mod tests {
|
|||||||
assert!(!object.is_inline_fast_path_eligible(), "transitioned objects must fall back");
|
assert!(!object.is_inline_fast_path_eligible(), "transitioned objects must fall back");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minio_internal_encryption_metadata_is_not_treated_as_plaintext() {
|
||||||
|
let object = ObjectInfo {
|
||||||
|
user_defined: Arc::new(HashMap::from([(
|
||||||
|
"X-Minio-Internal-Server-Side-Encryption-Sealed-Key".to_string(),
|
||||||
|
"sealed".to_string(),
|
||||||
|
)])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(object.is_encrypted());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn versions_after_marker_handles_null_version_marker() {
|
fn versions_after_marker_handles_null_version_marker() {
|
||||||
let first_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
|
let first_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ use crate::bucket::metadata_sys::BucketMetadataSys;
|
|||||||
use crate::bucket::replication::{DynReplicationPool, ReplicationStats};
|
use crate::bucket::replication::{DynReplicationPool, ReplicationStats};
|
||||||
use crate::disk::DiskStore;
|
use crate::disk::DiskStore;
|
||||||
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
||||||
|
use crate::object_api::ObjectEncryptionResolver;
|
||||||
use crate::services::event_notification::EventNotifier;
|
use crate::services::event_notification::EventNotifier;
|
||||||
use crate::services::tier::tier::TierConfigMgr;
|
use crate::services::tier::tier::TierConfigMgr;
|
||||||
use rustfs_lock::{GlobalLockManager, get_global_lock_manager};
|
use rustfs_lock::{GlobalLockManager, get_global_lock_manager};
|
||||||
@@ -159,6 +160,8 @@ pub struct InstanceContext {
|
|||||||
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
||||||
/// Replaces the process-global cancel-token static.
|
/// Replaces the process-global cancel-token static.
|
||||||
background_cancel_token: OnceLock<CancellationToken>,
|
background_cancel_token: OnceLock<CancellationToken>,
|
||||||
|
/// Resolves object-encryption material at the application boundary.
|
||||||
|
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -197,6 +200,7 @@ impl InstanceContext {
|
|||||||
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
||||||
bucket_metadata_sys: std::sync::Mutex::new(None),
|
bucket_metadata_sys: std::sync::Mutex::new(None),
|
||||||
background_cancel_token: OnceLock::new(),
|
background_cancel_token: OnceLock::new(),
|
||||||
|
object_encryption_resolver: OnceLock::new(),
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -209,6 +213,19 @@ impl InstanceContext {
|
|||||||
self.lock_manager.clone()
|
self.lock_manager.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install the application-owned object-encryption resolver once.
|
||||||
|
pub fn set_object_encryption_resolver(
|
||||||
|
&self,
|
||||||
|
resolver: Arc<dyn ObjectEncryptionResolver>,
|
||||||
|
) -> Result<(), Arc<dyn ObjectEncryptionResolver>> {
|
||||||
|
self.object_encryption_resolver.set(resolver)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the configured object-encryption resolver, if startup installed one.
|
||||||
|
pub fn object_encryption_resolver(&self) -> Option<&dyn ObjectEncryptionResolver> {
|
||||||
|
self.object_encryption_resolver.get().map(Arc::as_ref)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set this instance's S3 region.
|
/// Set this instance's S3 region.
|
||||||
///
|
///
|
||||||
/// Write-once: panics on a second write, preserving the startup fail-fast
|
/// Write-once: panics on a second write, preserving the startup fail-fast
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ use crate::{
|
|||||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||||
use rustfs_config::server_config::{Config, get_global_server_config, set_global_server_config};
|
use rustfs_config::server_config::{Config, get_global_server_config, set_global_server_config};
|
||||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||||
use rustfs_kms::{ObjectEncryptionService, get_global_encryption_service};
|
|
||||||
use rustfs_lock::client::LockClient;
|
use rustfs_lock::client::LockClient;
|
||||||
use s3s::dto::BucketLifecycleConfiguration;
|
use s3s::dto::BucketLifecycleConfiguration;
|
||||||
use s3s::region::Region;
|
use s3s::region::Region;
|
||||||
@@ -105,10 +104,6 @@ pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_
|
|||||||
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
|
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn object_encryption_service() -> Option<Arc<ObjectEncryptionService>> {
|
|
||||||
get_global_encryption_service().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn object_store_handle() -> Option<Arc<ECStore>> {
|
pub fn object_store_handle() -> Option<Arc<ECStore>> {
|
||||||
resolve_object_store_handle()
|
resolve_object_store_handle()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,9 +505,7 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn file_info_has_encryption_metadata(meta: &FileInfo) -> bool {
|
fn file_info_has_encryption_metadata(meta: &FileInfo) -> bool {
|
||||||
meta.metadata
|
meta.metadata.keys().any(|name| http::is_object_encryption_marker(name))
|
||||||
.keys()
|
|
||||||
.any(|name| http::is_encryption_metadata_key(name) || http::is_sse_header(name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
|
fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
|
||||||
|
|||||||
@@ -147,15 +147,17 @@ use rustfs_object_capacity::capacity_scope::{
|
|||||||
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
|
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
|
||||||
};
|
};
|
||||||
use rustfs_s3_types::EventName;
|
use rustfs_s3_types::EventName;
|
||||||
|
#[cfg(test)]
|
||||||
|
use rustfs_utils::http::SSEC_ALGORITHM_HEADER;
|
||||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||||
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
|
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
|
||||||
use rustfs_utils::http::headers::{
|
use rustfs_utils::http::headers::{
|
||||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
||||||
};
|
};
|
||||||
use rustfs_utils::http::{
|
use rustfs_utils::http::{
|
||||||
SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE,
|
SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC,
|
||||||
SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_RESTORE_OPERATION_ID, contains_key_str,
|
SUFFIX_RESTORE_OPERATION_ID, contains_key_str, get_header_map, get_str, insert_str, is_object_encryption_marker,
|
||||||
get_header_map, get_str, insert_str, is_encryption_metadata_key, remove_header_map,
|
remove_header_map,
|
||||||
};
|
};
|
||||||
use rustfs_utils::{
|
use rustfs_utils::{
|
||||||
HashAlgorithm,
|
HashAlgorithm,
|
||||||
@@ -670,10 +672,7 @@ pub(crate) fn strip_internal_multipart_metadata(metadata: &mut HashMap<String, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
|
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
|
||||||
metadata.keys().any(|key| is_encryption_metadata_key(key))
|
metadata.keys().any(|key| is_object_encryption_marker(key))
|
||||||
|| metadata.contains_key(SSEC_ALGORITHM_HEADER)
|
|
||||||
|| metadata.contains_key(SSEC_KEY_HEADER)
|
|
||||||
|| metadata.contains_key(SSEC_KEY_MD5_HEADER)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-set memoized capacity dirty scope.
|
/// Per-set memoized capacity dirty scope.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ use crate::object_api::{GetObjectBodySource, get_object_body_cache_hook_suppress
|
|||||||
use crate::services::tier::tier::{TierConfigMgr, TierOperationLease};
|
use crate::services::tier::tier::{TierConfigMgr, TierOperationLease};
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
use futures::FutureExt as _;
|
use futures::FutureExt as _;
|
||||||
|
use http::HeaderValue;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Erasure> {
|
fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Erasure> {
|
||||||
@@ -49,6 +50,17 @@ fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Er
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_object_reader_with_context(
|
||||||
|
ctx: &InstanceContext,
|
||||||
|
reader: Box<dyn AsyncRead + Unpin + Send + Sync>,
|
||||||
|
range: Option<HTTPRangeSpec>,
|
||||||
|
object_info: &ObjectInfo,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
headers: &HeaderMap<HeaderValue>,
|
||||||
|
) -> Result<(GetObjectReader, usize, i64)> {
|
||||||
|
GetObjectReader::new_with_resolver(reader, range, object_info, opts, headers, ctx.object_encryption_resolver()).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Length of the full plaintext body when — and only when — this read's output
|
/// Length of the full plaintext body when — and only when — this read's output
|
||||||
/// is exactly the object's complete plaintext, so the app-layer body cache may
|
/// is exactly the object's complete plaintext, so the app-layer body cache may
|
||||||
/// serve it in place of the erasure read.
|
/// serve it in place of the erasure read.
|
||||||
@@ -713,7 +725,8 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
|||||||
size_bucket,
|
size_bucket,
|
||||||
);
|
);
|
||||||
record_get_object_reader_path_observation(GET_OBJECT_PATH_CODEC_STREAMING, object_class, size_bucket);
|
record_get_object_reader_path_observation(GET_OBJECT_PATH_CODEC_STREAMING, object_class, size_bucket);
|
||||||
let (mut reader, _offset, _length) = GetObjectReader::new(stream, range, &object_info, opts, &h).await?;
|
let (mut reader, _offset, _length) =
|
||||||
|
get_object_reader_with_context(&self.ctx, stream, range, &object_info, opts, &h).await?;
|
||||||
// Carry the hook probe result so the app layer skips its
|
// Carry the hook probe result so the app layer skips its
|
||||||
// now-redundant lookup on the streaming miss path (ODC-16).
|
// now-redundant lookup on the streaming miss path (ODC-16).
|
||||||
reader.body_source = body_source;
|
reader.body_source = body_source;
|
||||||
@@ -745,7 +758,8 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
|||||||
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
||||||
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
||||||
|
|
||||||
let (mut reader, offset, length) = GetObjectReader::new(Box::new(rd), range, &object_info, opts, &h).await?;
|
let (mut reader, offset, length) =
|
||||||
|
get_object_reader_with_context(&self.ctx, Box::new(rd), range, &object_info, opts, &h).await?;
|
||||||
// Carry the hook probe result so the app layer skips its now-redundant
|
// Carry the hook probe result so the app layer skips its now-redundant
|
||||||
// lookup on the streaming miss path (ODC-16).
|
// lookup on the streaming miss path (ODC-16).
|
||||||
reader.body_source = body_source;
|
reader.body_source = body_source;
|
||||||
@@ -4536,6 +4550,61 @@ mod erasure_construction_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod object_encryption_resolver_wiring_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::object_api::{EncryptionResolutionError, ObjectEncryptionResolver, ReadEncryptionMaterial, ReadEncryptionRequest};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
struct CountingResolver {
|
||||||
|
calls: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ObjectEncryptionResolver for CountingResolver {
|
||||||
|
async fn resolve_read_material(
|
||||||
|
&self,
|
||||||
|
_request: ReadEncryptionRequest<'_>,
|
||||||
|
) -> std::result::Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError> {
|
||||||
|
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_object_reader_forwards_instance_resolver() {
|
||||||
|
let resolver = Arc::new(CountingResolver {
|
||||||
|
calls: AtomicUsize::new(0),
|
||||||
|
});
|
||||||
|
let ctx = InstanceContext::new();
|
||||||
|
assert!(
|
||||||
|
ctx.set_object_encryption_resolver(resolver.clone()).is_ok(),
|
||||||
|
"fresh context should accept resolver"
|
||||||
|
);
|
||||||
|
let object_info = ObjectInfo {
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
name: "object".to_string(),
|
||||||
|
size: 1,
|
||||||
|
user_defined: Arc::new(HashMap::from([("x-amz-server-side-encryption".to_string(), "AES256".to_string())])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = get_object_reader_with_context(
|
||||||
|
&ctx,
|
||||||
|
Box::new(Cursor::new(Vec::<u8>::new())),
|
||||||
|
None,
|
||||||
|
&object_info,
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
&HeaderMap::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err(), "resolver returning no material must fail closed");
|
||||||
|
assert_eq!(resolver.calls.load(Ordering::Relaxed), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
||||||
//! Shared hermetic `SetDisks` construction for the ops tests below: the
|
//! Shared hermetic `SetDisks` construction for the ops tests below: the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## MinIO-generated encrypted fixtures
|
## MinIO-generated encrypted fixtures
|
||||||
|
|
||||||
`minio_generated_read_test.rs` validates the `bitrot -> GetObjectReader` path against raw MinIO backend data captured by
|
`rustfs/src/storage/minio_generated_read_test.rs` validates the `bitrot -> GetObjectReader` path against raw MinIO backend data captured by
|
||||||
`.\rustfs\scripts\minio_fixture_lab\lab.py`.
|
`.\rustfs\scripts\minio_fixture_lab\lab.py`.
|
||||||
|
|
||||||
It currently covers multipart fixtures for:
|
It currently covers multipart fixtures for:
|
||||||
@@ -20,5 +20,5 @@ Example:
|
|||||||
```powershell
|
```powershell
|
||||||
$env:RUSTFS_MINIO_FIXTURE_ROOT = '.\rustfs\tmp\minio-fixture-lab-local-key'
|
$env:RUSTFS_MINIO_FIXTURE_ROOT = '.\rustfs\tmp\minio-fixture-lab-local-key'
|
||||||
$env:RUSTFS_MINIO_STATIC_KMS_KEY_B64 = '<base64-32-byte-local-minio-kms-key>'
|
$env:RUSTFS_MINIO_STATIC_KMS_KEY_B64 = '<base64-32-byte-local-minio-kms-key>'
|
||||||
cargo +1.97.1 test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored
|
cargo +1.97.1 test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,3 +25,25 @@ For local KMS end-to-end tests, keep proxy bypass settings:
|
|||||||
NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \
|
NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \
|
||||||
cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1
|
cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local Key Export for SSE-S3 Migration Tests
|
||||||
|
|
||||||
|
Use the read-only `local_kms_key_decrypt` example to export an AES-256 Local
|
||||||
|
KMS key as the base64 value expected by `RUSTFS_SSE_S3_MASTER_KEY`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RUSTFS_KMS_LOCAL_MASTER_KEY='<local-kms-at-rest-master-key>'
|
||||||
|
export RUSTFS_SSE_S3_MASTER_KEY="$(
|
||||||
|
cargo run -q -p rustfs-kms --example local_kms_key_decrypt -- \
|
||||||
|
/absolute/path/to/<key-id>.key
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
For a `plaintext-dev-only` Local KMS key file,
|
||||||
|
`RUSTFS_KMS_LOCAL_MASTER_KEY` is not required.
|
||||||
|
|
||||||
|
The example writes only the base64-encoded 32-byte key to stdout. Diagnostics
|
||||||
|
go to stderr. Never paste its output into logs, shell history, issue comments,
|
||||||
|
or committed configuration. The export path must remain read-only and must
|
||||||
|
reuse `LocalKmsClient` decoding so current Argon2id and legacy key-file
|
||||||
|
compatibility stay aligned with the backend.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// 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 base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
use rustfs_kms::{LocalConfig, backends::local::LocalKmsClient};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
const LOCAL_KMS_MASTER_KEY_ENV: &str = "RUSTFS_KMS_LOCAL_MASTER_KEY";
|
||||||
|
|
||||||
|
fn usage(program: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"Usage: {program} <local-kms-key-file>\n\
|
||||||
|
Reads {LOCAL_KMS_MASTER_KEY_ENV} when the key file is encrypted.\n\
|
||||||
|
Writes only the base64-encoded 32-byte key to stdout."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_key_file(path: &Path) -> Result<(PathBuf, String), String> {
|
||||||
|
let canonical = std::fs::canonicalize(path).map_err(|error| format!("cannot open Local KMS key file: {error}"))?;
|
||||||
|
if canonical.extension().and_then(|extension| extension.to_str()) != Some("key") {
|
||||||
|
return Err("Local KMS key file must have a .key extension".to_string());
|
||||||
|
}
|
||||||
|
let key_dir = canonical
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Local KMS key file must have a parent directory".to_string())?
|
||||||
|
.to_path_buf();
|
||||||
|
let key_id = canonical
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.filter(|stem| !stem.is_empty())
|
||||||
|
.ok_or_else(|| "Local KMS key file name must contain a valid UTF-8 key ID".to_string())?
|
||||||
|
.to_string();
|
||||||
|
Ok((key_dir, key_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), String> {
|
||||||
|
let mut args = std::env::args();
|
||||||
|
let program = args.next().unwrap_or_else(|| "local_kms_key_decrypt".to_string());
|
||||||
|
let Some(key_file) = args.next() else {
|
||||||
|
return Err(usage(&program));
|
||||||
|
};
|
||||||
|
if args.next().is_some() {
|
||||||
|
return Err(usage(&program));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (key_dir, key_id) = resolve_key_file(Path::new(&key_file))?;
|
||||||
|
let master_key = std::env::var(LOCAL_KMS_MASTER_KEY_ENV).ok().filter(|value| !value.is_empty());
|
||||||
|
let client = LocalKmsClient::new_for_key_export(LocalConfig {
|
||||||
|
key_dir,
|
||||||
|
master_key,
|
||||||
|
file_permissions: Some(0o600),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let key_material = client
|
||||||
|
.decrypt_key_material_for_export(&key_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let encoded = Zeroizing::new(BASE64_STANDARD.encode(key_material.as_ref()));
|
||||||
|
|
||||||
|
let mut stdout = io::stdout().lock();
|
||||||
|
writeln!(stdout, "{}", encoded.as_str()).map_err(|error| format!("failed to write decrypted key: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if let Err(error) = run().await {
|
||||||
|
let _ = writeln!(io::stderr().lock(), "local_kms_key_decrypt: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_key_file_extracts_directory_and_key_id() {
|
||||||
|
let directory = tempfile::tempdir().expect("create temporary directory");
|
||||||
|
let key_file = directory.path().join("migration-key.key");
|
||||||
|
std::fs::write(&key_file, b"{}").expect("create key file");
|
||||||
|
|
||||||
|
let (key_dir, key_id) = resolve_key_file(&key_file).expect("resolve key file");
|
||||||
|
|
||||||
|
assert_eq!(key_dir, directory.path().canonicalize().expect("canonical directory"));
|
||||||
|
assert_eq!(key_id, "migration-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_key_file_rejects_non_key_extension() {
|
||||||
|
let directory = tempfile::tempdir().expect("create temporary directory");
|
||||||
|
let key_file = directory.path().join("migration-key.json");
|
||||||
|
std::fs::write(&key_file, b"{}").expect("create key file");
|
||||||
|
|
||||||
|
let error = resolve_key_file(&key_file).expect_err("non-key file must be rejected");
|
||||||
|
|
||||||
|
assert!(error.contains(".key"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ use std::path::{Component, Path, PathBuf};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
/// Reject key identifiers that would not name a single file directly inside the key
|
/// Reject key identifiers that would not name a single file directly inside the key
|
||||||
/// directory.
|
/// directory.
|
||||||
@@ -146,6 +147,45 @@ impl LocalKmsClient {
|
|||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a Local KMS key directory without creating or modifying any files.
|
||||||
|
///
|
||||||
|
/// This constructor is restricted to explicit key-export tooling. Normal
|
||||||
|
/// backend operation must use [`Self::new`].
|
||||||
|
pub async fn new_for_key_export(config: LocalConfig) -> Result<Self> {
|
||||||
|
if !fs::try_exists(&config.key_dir).await? {
|
||||||
|
return Err(KmsError::configuration_error("Local KMS key directory does not exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (master_cipher, legacy_master_cipher) = if let Some(ref master_key) = config.master_key {
|
||||||
|
let legacy_key = Self::derive_legacy_master_key(master_key)?;
|
||||||
|
let legacy_master_cipher = Aes256Gcm::new(&legacy_key);
|
||||||
|
let salt_path = Self::master_key_salt_path(&config);
|
||||||
|
let master_cipher = if fs::try_exists(&salt_path).await? {
|
||||||
|
let salt = fs::read(&salt_path).await?;
|
||||||
|
let salt: [u8; LOCAL_KMS_MASTER_KEY_SALT_LEN] = salt.try_into().map_err(|_| {
|
||||||
|
KmsError::configuration_error(format!(
|
||||||
|
"Local KMS master key salt at {} must be exactly {} bytes",
|
||||||
|
salt_path.display(),
|
||||||
|
LOCAL_KMS_MASTER_KEY_SALT_LEN
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Aes256Gcm::new(&Self::derive_master_key(master_key, &salt)?)
|
||||||
|
} else {
|
||||||
|
Aes256Gcm::new(&legacy_key)
|
||||||
|
};
|
||||||
|
(Some(master_cipher), Some(legacy_master_cipher))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
master_cipher,
|
||||||
|
legacy_master_cipher,
|
||||||
|
dek_crypto: AesDekCrypto::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive a 256-bit key from the master key string using a persistent Argon2id salt.
|
/// Derive a 256-bit key from the master key string using a persistent Argon2id salt.
|
||||||
fn derive_master_key(master_key: &str, salt: &[u8]) -> Result<Key<Aes256Gcm>> {
|
fn derive_master_key(master_key: &str, salt: &[u8]) -> Result<Key<Aes256Gcm>> {
|
||||||
let params = Params::new(
|
let params = Params::new(
|
||||||
@@ -435,6 +475,20 @@ impl LocalKmsClient {
|
|||||||
Ok(key_material)
|
Ok(key_material)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt an AES-256 Local KMS key for explicit migration tooling.
|
||||||
|
///
|
||||||
|
/// The returned buffer is zeroized on drop. Callers must treat the value as
|
||||||
|
/// plaintext key material and avoid logging or persisting it.
|
||||||
|
pub async fn decrypt_key_material_for_export(&self, key_id: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let (stored_key, key_material) = self.decode_stored_key(key_id).await?;
|
||||||
|
if stored_key.algorithm != "AES_256" {
|
||||||
|
return Err(KmsError::unsupported_algorithm(stored_key.algorithm));
|
||||||
|
}
|
||||||
|
let actual = key_material.len();
|
||||||
|
let key_material = key_material.try_into().map_err(|_| KmsError::invalid_key_size(32, actual))?;
|
||||||
|
Ok(Zeroizing::new(key_material))
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate_existing_keys(&self) -> Result<()> {
|
async fn validate_existing_keys(&self) -> Result<()> {
|
||||||
let mut entries = fs::read_dir(&self.config.key_dir).await?;
|
let mut entries = fs::read_dir(&self.config.key_dir).await?;
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
@@ -1208,6 +1262,52 @@ mod tests {
|
|||||||
assert!(matches!(wrong_master_error, KmsError::CryptographicError { .. }));
|
assert!(matches!(wrong_master_error, KmsError::CryptographicError { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn key_export_uses_existing_local_decryption_path_without_writing_files() {
|
||||||
|
let (client, _temp_dir) = create_test_client().await;
|
||||||
|
let key_id = "export-key";
|
||||||
|
client
|
||||||
|
.create_key(key_id, "AES_256", None)
|
||||||
|
.await
|
||||||
|
.expect("create encrypted key");
|
||||||
|
let expected = client.get_key_material(key_id).await.expect("load expected key material");
|
||||||
|
let salt_path = LocalKmsClient::master_key_salt_path(&client.config);
|
||||||
|
let salt_before = fs::read(&salt_path).await.expect("read existing salt");
|
||||||
|
|
||||||
|
let export_client = LocalKmsClient::new_for_key_export(client.config.clone())
|
||||||
|
.await
|
||||||
|
.expect("open read-only export client");
|
||||||
|
let exported = export_client
|
||||||
|
.decrypt_key_material_for_export(key_id)
|
||||||
|
.await
|
||||||
|
.expect("decrypt key for export");
|
||||||
|
|
||||||
|
assert_eq!(exported.as_ref(), expected.as_slice());
|
||||||
|
assert_eq!(fs::read(&salt_path).await.expect("read unchanged salt"), salt_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn key_export_accepts_plaintext_dev_only_key_without_master_key() {
|
||||||
|
let (client, _temp_dir) = create_dev_mode_client().await;
|
||||||
|
let key_id = "plaintext-export-key";
|
||||||
|
client
|
||||||
|
.create_key(key_id, "AES_256", None)
|
||||||
|
.await
|
||||||
|
.expect("create plaintext-dev-only key");
|
||||||
|
let expected = client.get_key_material(key_id).await.expect("load expected key material");
|
||||||
|
|
||||||
|
let export_client = LocalKmsClient::new_for_key_export(client.config.clone())
|
||||||
|
.await
|
||||||
|
.expect("open read-only export client");
|
||||||
|
let exported = export_client
|
||||||
|
.decrypt_key_material_for_export(key_id)
|
||||||
|
.await
|
||||||
|
.expect("export plaintext-dev-only key");
|
||||||
|
|
||||||
|
assert_eq!(exported.as_ref(), expected.as_slice());
|
||||||
|
assert!(!LocalKmsClient::master_key_salt_path(&client.config).exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_plaintext_dev_only_storage_is_explicit_and_loadable() {
|
async fn test_plaintext_dev_only_storage_is_explicit_and_loadable() {
|
||||||
let (client, _temp_dir) = create_dev_mode_client().await;
|
let (client, _temp_dir) = create_dev_mode_client().await;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub use error::{KmsError, KmsUnavailableError, Result};
|
|||||||
pub use manager::KmsManager;
|
pub use manager::KmsManager;
|
||||||
pub use service::{DataKey, ObjectEncryptionService};
|
pub use service::{DataKey, ObjectEncryptionService};
|
||||||
pub use service_manager::{
|
pub use service_manager::{
|
||||||
KmsServiceManager, KmsServiceStatus, get_global_encryption_service, get_global_kms_service_manager,
|
KmsServiceManager, KmsServiceStatus, KmsStartOutcome, get_global_encryption_service, get_global_kms_service_manager,
|
||||||
init_global_kms_service_manager,
|
init_global_kms_service_manager,
|
||||||
};
|
};
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ use crate::manager::KmsManager;
|
|||||||
use crate::service::ObjectEncryptionService;
|
use crate::service::ObjectEncryptionService;
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::future::Future;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc, OnceLock,
|
Arc, OnceLock,
|
||||||
atomic::{AtomicU64, Ordering},
|
atomic::{AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
const LOG_COMPONENT_KMS: &str = "kms";
|
const LOG_COMPONENT_KMS: &str = "kms";
|
||||||
@@ -93,6 +94,13 @@ pub enum KmsServiceStatus {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum KmsStartOutcome {
|
||||||
|
Started,
|
||||||
|
Restarted,
|
||||||
|
AlreadyRunning,
|
||||||
|
}
|
||||||
|
|
||||||
/// Service version information for zero-downtime reconfiguration
|
/// Service version information for zero-downtime reconfiguration
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ServiceVersion {
|
struct ServiceVersion {
|
||||||
@@ -104,16 +112,17 @@ struct ServiceVersion {
|
|||||||
manager: Arc<KmsManager>,
|
manager: Arc<KmsManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RuntimeState {
|
||||||
|
config: Option<KmsConfig>,
|
||||||
|
status: KmsServiceStatus,
|
||||||
|
current_service: Option<ServiceVersion>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Dynamic KMS service manager with versioned services for zero-downtime reconfiguration
|
/// Dynamic KMS service manager with versioned services for zero-downtime reconfiguration
|
||||||
pub struct KmsServiceManager {
|
pub struct KmsServiceManager {
|
||||||
/// Current service version (if running)
|
/// Atomically published configuration, status, and current service.
|
||||||
/// Uses ArcSwap for atomic, lock-free service switching
|
state: ArcSwap<RuntimeState>,
|
||||||
/// This allows instant atomic updates without blocking readers
|
|
||||||
current_service: ArcSwap<Option<ServiceVersion>>,
|
|
||||||
/// Current configuration
|
|
||||||
config: Arc<RwLock<Option<KmsConfig>>>,
|
|
||||||
/// Current status
|
|
||||||
status: Arc<RwLock<KmsServiceStatus>>,
|
|
||||||
/// Version counter (monotonically increasing)
|
/// Version counter (monotonically increasing)
|
||||||
version_counter: Arc<AtomicU64>,
|
version_counter: Arc<AtomicU64>,
|
||||||
/// Mutex to protect lifecycle operations (start, stop, reconfigure)
|
/// Mutex to protect lifecycle operations (start, stop, reconfigure)
|
||||||
@@ -125,9 +134,11 @@ impl KmsServiceManager {
|
|||||||
/// Create a new KMS service manager (not configured)
|
/// Create a new KMS service manager (not configured)
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
current_service: ArcSwap::from_pointee(None),
|
state: ArcSwap::from_pointee(RuntimeState {
|
||||||
config: Arc::new(RwLock::new(None)),
|
config: None,
|
||||||
status: Arc::new(RwLock::new(KmsServiceStatus::NotConfigured)),
|
status: KmsServiceStatus::NotConfigured,
|
||||||
|
current_service: None,
|
||||||
|
}),
|
||||||
version_counter: Arc::new(AtomicU64::new(0)),
|
version_counter: Arc::new(AtomicU64::new(0)),
|
||||||
lifecycle_mutex: Arc::new(Mutex::new(())),
|
lifecycle_mutex: Arc::new(Mutex::new(())),
|
||||||
}
|
}
|
||||||
@@ -135,44 +146,67 @@ impl KmsServiceManager {
|
|||||||
|
|
||||||
/// Get current service status
|
/// Get current service status
|
||||||
pub async fn get_status(&self) -> KmsServiceStatus {
|
pub async fn get_status(&self) -> KmsServiceStatus {
|
||||||
self.status.read().await.clone()
|
self.state.load().status.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current configuration (if any)
|
/// Get current configuration (if any)
|
||||||
pub async fn get_config(&self) -> Option<KmsConfig> {
|
pub async fn get_config(&self) -> Option<KmsConfig> {
|
||||||
self.config.read().await.clone()
|
self.state.load().config.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get configuration for status and management responses without static key material.
|
/// Get configuration for status and management responses without static key material.
|
||||||
pub async fn get_redacted_config(&self) -> Option<KmsConfig> {
|
pub async fn get_redacted_config(&self) -> Option<KmsConfig> {
|
||||||
let mut config = self.config.read().await.clone()?;
|
let mut config = self.state.load().config.clone()?;
|
||||||
|
Self::redact_config(&mut config);
|
||||||
|
Some(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get status and redacted configuration from the same published snapshot.
|
||||||
|
pub async fn get_redacted_state(&self) -> (KmsServiceStatus, Option<KmsConfig>) {
|
||||||
|
let state = self.state.load();
|
||||||
|
let mut config = state.config.clone();
|
||||||
|
if let Some(config) = &mut config {
|
||||||
|
Self::redact_config(config);
|
||||||
|
}
|
||||||
|
(state.status.clone(), config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_config(config: &mut KmsConfig) {
|
||||||
if let BackendConfig::Static(static_config) = &mut config.backend_config {
|
if let BackendConfig::Static(static_config) = &mut config.backend_config {
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
static_config.secret_key.zeroize();
|
static_config.secret_key.zeroize();
|
||||||
}
|
}
|
||||||
Some(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure KMS with new configuration
|
/// Configure KMS with new configuration
|
||||||
pub async fn configure(&self, new_config: KmsConfig) -> Result<()> {
|
pub async fn configure(&self, new_config: KmsConfig) -> Result<()> {
|
||||||
let _guard = self.lifecycle_mutex.lock().await;
|
self.configure_with_persistence(new_config, || async { Ok(()) }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure KMS and publish the in-memory state only after persistence succeeds.
|
||||||
|
///
|
||||||
|
/// The persistence callback runs under the lifecycle lock and must not call
|
||||||
|
/// another lifecycle method on this manager.
|
||||||
|
pub async fn configure_with_persistence<Persist, PersistFuture>(&self, new_config: KmsConfig, persist: Persist) -> Result<()>
|
||||||
|
where
|
||||||
|
Persist: FnOnce() -> PersistFuture,
|
||||||
|
PersistFuture: Future<Output = Result<()>>,
|
||||||
|
{
|
||||||
new_config.validate()?;
|
new_config.validate()?;
|
||||||
{
|
let _guard = self.lifecycle_mutex.lock().await;
|
||||||
let config = self.config.read().await;
|
let current = self.state.load_full();
|
||||||
validate_local_transition(config.as_ref(), &new_config)?;
|
validate_local_transition(current.config.as_ref(), &new_config)?;
|
||||||
}
|
if current.current_service.is_some() {
|
||||||
|
return Err(KmsError::configuration_error(
|
||||||
// Update configuration
|
"Cannot configure KMS while it is running; use reconfigure instead",
|
||||||
{
|
));
|
||||||
let mut config = self.config.write().await;
|
|
||||||
*config = Some(new_config.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
{
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = KmsServiceStatus::Configured;
|
|
||||||
}
|
}
|
||||||
|
persist().await?;
|
||||||
|
self.state.store(Arc::new(RuntimeState {
|
||||||
|
config: Some(new_config),
|
||||||
|
status: KmsServiceStatus::Configured,
|
||||||
|
current_service: None,
|
||||||
|
}));
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_KMS_SERVICE_STATE,
|
event = EVENT_KMS_SERVICE_STATE,
|
||||||
@@ -190,19 +224,35 @@ impl KmsServiceManager {
|
|||||||
self.start_internal().await
|
self.start_internal().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start or restart KMS with the running-state decision serialized with the lifecycle action.
|
||||||
|
pub async fn start_or_restart(&self, force: bool) -> Result<KmsStartOutcome> {
|
||||||
|
let _guard = self.lifecycle_mutex.lock().await;
|
||||||
|
let running = self.state.load().current_service.is_some();
|
||||||
|
if running && !force {
|
||||||
|
return Ok(KmsStartOutcome::AlreadyRunning);
|
||||||
|
}
|
||||||
|
self.start_internal().await?;
|
||||||
|
Ok(if running {
|
||||||
|
KmsStartOutcome::Restarted
|
||||||
|
} else {
|
||||||
|
KmsStartOutcome::Started
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Internal start implementation (called within lifecycle mutex)
|
/// Internal start implementation (called within lifecycle mutex)
|
||||||
async fn start_internal(&self) -> Result<()> {
|
async fn start_internal(&self) -> Result<()> {
|
||||||
let config = {
|
let state = self.state.load_full();
|
||||||
let config_guard = self.config.read().await;
|
let config = match state.config.as_ref() {
|
||||||
match config_guard.as_ref() {
|
Some(config) => config.clone(),
|
||||||
Some(config) => config.clone(),
|
None => {
|
||||||
None => {
|
let err_msg = "Cannot start KMS: no configuration provided";
|
||||||
let err_msg = "Cannot start KMS: no configuration provided";
|
error!("{}", err_msg);
|
||||||
error!("{}", err_msg);
|
self.state.store(Arc::new(RuntimeState {
|
||||||
let mut status = self.status.write().await;
|
config: None,
|
||||||
*status = KmsServiceStatus::Error(err_msg.to_string());
|
status: KmsServiceStatus::Error(err_msg.to_string()),
|
||||||
return Err(KmsError::configuration_error(err_msg));
|
current_service: None,
|
||||||
}
|
}));
|
||||||
|
return Err(KmsError::configuration_error(err_msg));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,17 +265,9 @@ impl KmsServiceManager {
|
|||||||
"KMS service starting"
|
"KMS service starting"
|
||||||
);
|
);
|
||||||
|
|
||||||
match self.create_service_version(&config).await {
|
match self.create_healthy_service_version(&config).await {
|
||||||
Ok(service_version) => {
|
Ok(service_version) => {
|
||||||
// Atomically update to new service version (lock-free, instant)
|
self.publish_running(config, service_version);
|
||||||
// ArcSwap::store() is a true atomic operation using CAS
|
|
||||||
self.current_service.store(Arc::new(Some(service_version)));
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
{
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = KmsServiceStatus::Running;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_KMS_SERVICE_STATE,
|
event = EVENT_KMS_SERVICE_STATE,
|
||||||
@@ -239,13 +281,24 @@ impl KmsServiceManager {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_msg = format!("Failed to create KMS backend: {e}");
|
let err_msg = format!("Failed to create KMS backend: {e}");
|
||||||
error!("{}", err_msg);
|
error!("{}", err_msg);
|
||||||
let mut status = self.status.write().await;
|
if state.current_service.is_none() {
|
||||||
*status = KmsServiceStatus::Error(err_msg.clone());
|
self.state.store(Arc::new(RuntimeState {
|
||||||
|
config: state.config.clone(),
|
||||||
|
status: KmsServiceStatus::Error(err_msg.clone()),
|
||||||
|
current_service: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Err(KmsError::backend_error(&err_msg))
|
Err(KmsError::backend_error(&err_msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the running service without exposing a stopped interval.
|
||||||
|
pub async fn restart(&self) -> Result<()> {
|
||||||
|
let _guard = self.lifecycle_mutex.lock().await;
|
||||||
|
self.start_internal().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop KMS service
|
/// Stop KMS service
|
||||||
///
|
///
|
||||||
/// Note: This stops accepting new operations, but existing operations using
|
/// Note: This stops accepting new operations, but existing operations using
|
||||||
@@ -267,15 +320,16 @@ impl KmsServiceManager {
|
|||||||
|
|
||||||
// Atomically clear current service version (lock-free, instant)
|
// Atomically clear current service version (lock-free, instant)
|
||||||
// Note: Existing Arc references will keep the service alive until operations complete
|
// Note: Existing Arc references will keep the service alive until operations complete
|
||||||
self.current_service.store(Arc::new(None));
|
let state = self.state.load_full();
|
||||||
|
self.state.store(Arc::new(RuntimeState {
|
||||||
// Update status (keep configuration)
|
config: state.config.clone(),
|
||||||
{
|
status: if state.config.is_some() {
|
||||||
let mut status = self.status.write().await;
|
KmsServiceStatus::Configured
|
||||||
if !matches!(*status, KmsServiceStatus::NotConfigured) {
|
} else {
|
||||||
*status = KmsServiceStatus::Configured;
|
KmsServiceStatus::NotConfigured
|
||||||
}
|
},
|
||||||
}
|
current_service: None,
|
||||||
|
}));
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_KMS_SERVICE_STATE,
|
event = EVENT_KMS_SERVICE_STATE,
|
||||||
@@ -298,6 +352,22 @@ impl KmsServiceManager {
|
|||||||
/// This ensures zero downtime during reconfiguration, even for long-running
|
/// This ensures zero downtime during reconfiguration, even for long-running
|
||||||
/// operations like encrypting large files.
|
/// operations like encrypting large files.
|
||||||
pub async fn reconfigure(&self, new_config: KmsConfig) -> Result<()> {
|
pub async fn reconfigure(&self, new_config: KmsConfig) -> Result<()> {
|
||||||
|
self.reconfigure_with_persistence(new_config, || async { Ok(()) }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconfigure KMS after the candidate is healthy and persistence succeeds.
|
||||||
|
///
|
||||||
|
/// The persistence callback runs under the lifecycle lock and must not call
|
||||||
|
/// another lifecycle method on this manager.
|
||||||
|
pub async fn reconfigure_with_persistence<Persist, PersistFuture>(
|
||||||
|
&self,
|
||||||
|
new_config: KmsConfig,
|
||||||
|
persist: Persist,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
Persist: FnOnce() -> PersistFuture,
|
||||||
|
PersistFuture: Future<Output = Result<()>>,
|
||||||
|
{
|
||||||
let _guard = self.lifecycle_mutex.lock().await;
|
let _guard = self.lifecycle_mutex.lock().await;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
@@ -308,33 +378,18 @@ impl KmsServiceManager {
|
|||||||
"KMS service reconfiguring"
|
"KMS service reconfiguring"
|
||||||
);
|
);
|
||||||
new_config.validate()?;
|
new_config.validate()?;
|
||||||
{
|
validate_local_transition(self.state.load().config.as_ref(), &new_config)?;
|
||||||
let config = self.config.read().await;
|
|
||||||
validate_local_transition(config.as_ref(), &new_config)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new service version without stopping old one
|
// Create new service version without stopping old one
|
||||||
// This allows existing operations to continue while new operations use new service
|
// This allows existing operations to continue while new operations use new service
|
||||||
match self.create_service_version(&new_config).await {
|
match self.create_healthy_service_version(&new_config).await {
|
||||||
Ok(new_service_version) => {
|
Ok(new_service_version) => {
|
||||||
// Get old version for logging (lock-free read)
|
// Get old version for logging (lock-free read)
|
||||||
let old_version = self.current_service.load().as_ref().as_ref().map(|sv| sv.version);
|
let old_version = self.state.load().current_service.as_ref().map(|sv| sv.version);
|
||||||
|
|
||||||
{
|
persist().await?;
|
||||||
let mut config = self.config.write().await;
|
|
||||||
*config = Some(new_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomically switch to new service version (lock-free, instant CAS operation)
|
self.publish_running(new_config, new_service_version.clone());
|
||||||
// This is a true atomic operation - no waiting for locks, instant switch
|
|
||||||
// Old service will be dropped when no more Arc references exist
|
|
||||||
self.current_service.store(Arc::new(Some(new_service_version.clone())));
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
{
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
*status = KmsServiceStatus::Running;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(old_ver) = old_version {
|
if let Some(old_ver) = old_version {
|
||||||
info!(
|
info!(
|
||||||
@@ -371,7 +426,7 @@ impl KmsServiceManager {
|
|||||||
/// Returns the manager from the current service version.
|
/// Returns the manager from the current service version.
|
||||||
/// Uses lock-free atomic load for optimal performance.
|
/// Uses lock-free atomic load for optimal performance.
|
||||||
pub async fn get_manager(&self) -> Option<Arc<KmsManager>> {
|
pub async fn get_manager(&self) -> Option<Arc<KmsManager>> {
|
||||||
self.current_service.load().as_ref().as_ref().map(|sv| sv.manager.clone())
|
self.state.load().current_service.as_ref().map(|sv| sv.manager.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get encryption service (if running)
|
/// Get encryption service (if running)
|
||||||
@@ -381,7 +436,7 @@ impl KmsServiceManager {
|
|||||||
/// This ensures new operations always use the latest service version,
|
/// This ensures new operations always use the latest service version,
|
||||||
/// while existing operations continue using their Arc references.
|
/// while existing operations continue using their Arc references.
|
||||||
pub async fn get_encryption_service(&self) -> Option<Arc<ObjectEncryptionService>> {
|
pub async fn get_encryption_service(&self) -> Option<Arc<ObjectEncryptionService>> {
|
||||||
self.current_service.load().as_ref().as_ref().map(|sv| sv.service.clone())
|
self.state.load().current_service.as_ref().map(|sv| sv.service.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current service version number
|
/// Get current service version number
|
||||||
@@ -389,14 +444,16 @@ impl KmsServiceManager {
|
|||||||
/// Useful for monitoring and debugging.
|
/// Useful for monitoring and debugging.
|
||||||
/// Uses lock-free atomic load.
|
/// Uses lock-free atomic load.
|
||||||
pub async fn get_service_version(&self) -> Option<u64> {
|
pub async fn get_service_version(&self) -> Option<u64> {
|
||||||
self.current_service.load().as_ref().as_ref().map(|sv| sv.version)
|
self.state.load().current_service.as_ref().map(|sv| sv.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Health check for the KMS service
|
/// Health check for the KMS service
|
||||||
pub async fn health_check(&self) -> Result<bool> {
|
pub async fn health_check(&self) -> Result<bool> {
|
||||||
let manager = self.get_manager().await;
|
let checked_state = self.state.load_full();
|
||||||
match manager {
|
match checked_state.current_service.as_ref() {
|
||||||
Some(manager) => {
|
Some(service_version) => {
|
||||||
|
let manager = service_version.manager.clone();
|
||||||
|
let checked_version = service_version.version;
|
||||||
// Perform health check on the backend
|
// Perform health check on the backend
|
||||||
match manager.health_check().await {
|
match manager.health_check().await {
|
||||||
Ok(healthy) => {
|
Ok(healthy) => {
|
||||||
@@ -407,9 +464,8 @@ impl KmsServiceManager {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("KMS health check error: {}", e);
|
error!("KMS health check error: {}", e);
|
||||||
// Update status to error
|
let _guard = self.lifecycle_mutex.lock().await;
|
||||||
let mut status = self.status.write().await;
|
self.mark_health_error_if_current(checked_version, &e);
|
||||||
*status = KmsServiceStatus::Error(format!("Health check failed: {e}"));
|
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,6 +524,33 @@ impl KmsServiceManager {
|
|||||||
manager: kms_manager,
|
manager: kms_manager,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_healthy_service_version(&self, config: &KmsConfig) -> Result<ServiceVersion> {
|
||||||
|
let service_version = self.create_service_version(config).await?;
|
||||||
|
if !service_version.manager.health_check().await? {
|
||||||
|
return Err(KmsError::backend_error("KMS backend health check failed"));
|
||||||
|
}
|
||||||
|
Ok(service_version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_running(&self, config: KmsConfig, service_version: ServiceVersion) {
|
||||||
|
self.state.store(Arc::new(RuntimeState {
|
||||||
|
config: Some(config),
|
||||||
|
status: KmsServiceStatus::Running,
|
||||||
|
current_service: Some(service_version),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_health_error_if_current(&self, checked_version: u64, error: &KmsError) {
|
||||||
|
let current = self.state.load_full();
|
||||||
|
if current.current_service.as_ref().map(|version| version.version) == Some(checked_version) {
|
||||||
|
self.state.store(Arc::new(RuntimeState {
|
||||||
|
config: current.config.clone(),
|
||||||
|
status: KmsServiceStatus::Error(format!("Health check failed: {error}")),
|
||||||
|
current_service: current.current_service.clone(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KmsServiceManager {
|
impl Default for KmsServiceManager {
|
||||||
@@ -500,6 +583,11 @@ pub async fn get_global_encryption_service() -> Option<Arc<ObjectEncryptionServi
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
|
|
||||||
|
fn static_config(key_id: &str, fill: u8) -> KmsConfig {
|
||||||
|
KmsConfig::static_kms(key_id.to_string(), BASE64_STANDARD.encode([fill; 32]))
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn configure_rejects_insecure_development_defaults_before_state_update() {
|
async fn configure_rejects_insecure_development_defaults_before_state_update() {
|
||||||
@@ -517,8 +605,6 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn redacted_config_omits_static_key_material() {
|
async fn redacted_config_omits_static_key_material() {
|
||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
let manager = KmsServiceManager::new();
|
let manager = KmsServiceManager::new();
|
||||||
let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]);
|
let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]);
|
||||||
manager
|
manager
|
||||||
@@ -533,6 +619,136 @@ mod tests {
|
|||||||
assert!(static_config.secret_key.is_empty());
|
assert!(static_config.secret_key.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn configure_persistence_failure_leaves_state_unchanged() {
|
||||||
|
let manager = KmsServiceManager::new();
|
||||||
|
|
||||||
|
let result = manager
|
||||||
|
.configure_with_persistence(static_config("key-a", 0x11), || async { Err(KmsError::backend_error("persist failed")) })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(manager.get_status().await, KmsServiceStatus::NotConfigured);
|
||||||
|
assert!(manager.get_config().await.is_none());
|
||||||
|
assert!(manager.get_encryption_service().await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn configure_rejects_running_service_without_changing_snapshot() {
|
||||||
|
let manager = KmsServiceManager::new();
|
||||||
|
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||||
|
manager.start().await.expect("start");
|
||||||
|
let version = manager.get_service_version().await;
|
||||||
|
|
||||||
|
let result = manager.configure(static_config("key-b", 0x22)).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||||
|
assert_eq!(manager.get_service_version().await, version);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_config().await.and_then(|config| config.default_key_id),
|
||||||
|
Some("key-a".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconfigure_persistence_failure_keeps_old_running_snapshot() {
|
||||||
|
let manager = KmsServiceManager::new();
|
||||||
|
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||||
|
manager.start().await.expect("start");
|
||||||
|
let old_version = manager.get_service_version().await;
|
||||||
|
let old_service = manager.get_encryption_service().await.expect("old service");
|
||||||
|
|
||||||
|
let result = manager
|
||||||
|
.reconfigure_with_persistence(static_config("key-b", 0x22), || async {
|
||||||
|
Err(KmsError::backend_error("persist failed"))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||||
|
assert_eq!(manager.get_service_version().await, old_version);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_config().await.and_then(|config| config.default_key_id),
|
||||||
|
Some("key-a".to_string())
|
||||||
|
);
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
&old_service,
|
||||||
|
&manager.get_encryption_service().await.expect("old service remains")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconfigure_candidate_failure_keeps_old_running_snapshot() {
|
||||||
|
let manager = KmsServiceManager::new();
|
||||||
|
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||||
|
manager.start().await.expect("start");
|
||||||
|
let old_version = manager.get_service_version().await;
|
||||||
|
let invalid_parent = tempfile::NamedTempFile::new().expect("temporary file");
|
||||||
|
let invalid_config = KmsConfig::local(invalid_parent.path().join("keys")).with_insecure_development_defaults();
|
||||||
|
|
||||||
|
let result = manager.reconfigure(invalid_config).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||||
|
assert_eq!(manager.get_service_version().await, old_version);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_config().await.and_then(|config| config.default_key_id),
|
||||||
|
Some("key-a".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restart_never_unpublishes_the_running_service() {
|
||||||
|
let manager = Arc::new(KmsServiceManager::new());
|
||||||
|
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||||
|
manager.start().await.expect("start");
|
||||||
|
let old_version = manager.get_service_version().await.expect("old version");
|
||||||
|
let restarting = {
|
||||||
|
let manager = manager.clone();
|
||||||
|
tokio::spawn(async move { manager.restart().await })
|
||||||
|
};
|
||||||
|
|
||||||
|
while !restarting.is_finished() {
|
||||||
|
assert!(manager.get_encryption_service().await.is_some());
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
restarting.await.expect("restart task").expect("restart");
|
||||||
|
|
||||||
|
assert!(manager.get_encryption_service().await.is_some());
|
||||||
|
assert!(manager.get_service_version().await.expect("new version") > old_version);
|
||||||
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_or_restart_decides_under_the_lifecycle_lock() {
|
||||||
|
let manager = KmsServiceManager::new();
|
||||||
|
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||||
|
|
||||||
|
assert_eq!(manager.start_or_restart(false).await.expect("initial start"), KmsStartOutcome::Started);
|
||||||
|
let first_version = manager.get_service_version().await.expect("first version");
|
||||||
|
assert_eq!(
|
||||||
|
manager.start_or_restart(false).await.expect("already running"),
|
||||||
|
KmsStartOutcome::AlreadyRunning
|
||||||
|
);
|
||||||
|
assert_eq!(manager.get_service_version().await, Some(first_version));
|
||||||
|
assert_eq!(manager.start_or_restart(true).await.expect("forced restart"), KmsStartOutcome::Restarted);
|
||||||
|
assert!(manager.get_service_version().await.expect("restarted version") > first_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_health_failure_cannot_poison_new_service_status() {
|
||||||
|
let manager = KmsServiceManager::new();
|
||||||
|
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||||
|
manager.start().await.expect("start");
|
||||||
|
let old_version = manager.get_service_version().await.expect("old version");
|
||||||
|
manager.restart().await.expect("restart");
|
||||||
|
|
||||||
|
manager.mark_health_error_if_current(old_version, &KmsError::backend_error("stale failure"));
|
||||||
|
|
||||||
|
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn forbidden_local_master_key_change_preserves_running_config_and_service() {
|
async fn forbidden_local_master_key_change_preserves_running_config_and_service() {
|
||||||
use crate::types::{CreateKeyRequest, KeyUsage};
|
use crate::types::{CreateKeyRequest, KeyUsage};
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ tests read):
|
|||||||
./capture_via_docker.sh
|
./capture_via_docker.sh
|
||||||
|
|
||||||
RUSTFS_MINIO_STATIC_KMS_KEY_B64=IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g= \
|
RUSTFS_MINIO_STATIC_KMS_KEY_B64=IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g= \
|
||||||
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored
|
cargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored
|
||||||
```
|
```
|
||||||
|
|
||||||
This is exactly what the nightly `minio-interop` GitHub Actions workflow runs
|
This is exactly what the nightly `minio-interop` GitHub Actions workflow runs
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const RUSTFS_PREFIX: &str = "x-rustfs-";
|
|||||||
const MINIO_PREFIX: &str = "x-minio-";
|
const MINIO_PREFIX: &str = "x-minio-";
|
||||||
const MINIO_ENCRYPTION_PREFIX: &str = "x-minio-encryption-";
|
const MINIO_ENCRYPTION_PREFIX: &str = "x-minio-encryption-";
|
||||||
const RUSTFS_ENCRYPTION_PREFIX: &str = "x-rustfs-encryption-";
|
const RUSTFS_ENCRYPTION_PREFIX: &str = "x-rustfs-encryption-";
|
||||||
|
const MINIO_INTERNAL_ENCRYPTION_PREFIX: &str = "x-minio-internal-server-side-encryption-";
|
||||||
|
const MINIO_INTERNAL_ENCRYPTED_MULTIPART: &str = "x-minio-internal-encrypted-multipart";
|
||||||
|
const RUSTFS_ENCRYPTION_ORIGINAL_SIZE: &str = "x-rustfs-encryption-original-size";
|
||||||
|
const MINIO_ENCRYPTION_ORIGINAL_SIZE: &str = "x-minio-encryption-original-size";
|
||||||
|
const SSEC_ORIGINAL_SIZE: &str = "x-amz-server-side-encryption-customer-original-size";
|
||||||
|
|
||||||
// Suffix constants (part after x-rustfs- or x-minio-). Use with get_header/insert_header.
|
// Suffix constants (part after x-rustfs- or x-minio-). Use with get_header/insert_header.
|
||||||
pub const SUFFIX_FORCE_DELETE: &str = "force-delete";
|
pub const SUFFIX_FORCE_DELETE: &str = "force-delete";
|
||||||
@@ -40,11 +45,49 @@ pub const SUFFIX_SOURCE_REPLICATION_REQUEST: &str = "source-replication-request"
|
|||||||
pub const SUFFIX_SOURCE_REPLICATION_CHECK: &str = "source-replication-check";
|
pub const SUFFIX_SOURCE_REPLICATION_CHECK: &str = "source-replication-check";
|
||||||
pub const SUFFIX_REPLICATION_SSEC_CRC: &str = "replication-ssec-crc";
|
pub const SUFFIX_REPLICATION_SSEC_CRC: &str = "replication-ssec-crc";
|
||||||
|
|
||||||
/// Returns true if the key is an internal encryption metadata key (x-rustfs-encryption-* or
|
/// Returns true if the key is object-encryption metadata understood by RustFS or MinIO.
|
||||||
/// x-minio-encryption-*). Case-insensitive for metadata filtering.
|
/// Case-insensitive for metadata filtering.
|
||||||
pub fn is_encryption_metadata_key(key: &str) -> bool {
|
pub fn is_encryption_metadata_key(key: &str) -> bool {
|
||||||
let lower = key.to_lowercase();
|
let lower = key.to_lowercase();
|
||||||
lower.starts_with(RUSTFS_ENCRYPTION_PREFIX) || lower.starts_with(MINIO_ENCRYPTION_PREFIX)
|
lower.starts_with(RUSTFS_ENCRYPTION_PREFIX)
|
||||||
|
|| lower.starts_with(MINIO_ENCRYPTION_PREFIX)
|
||||||
|
|| lower.starts_with(MINIO_INTERNAL_ENCRYPTION_PREFIX)
|
||||||
|
|| lower == MINIO_INTERNAL_ENCRYPTED_MULTIPART
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when a metadata key proves that object data is encrypted.
|
||||||
|
///
|
||||||
|
/// Original-size metadata alone is not proof: older plaintext objects can
|
||||||
|
/// retain that compatibility field after metadata migration.
|
||||||
|
pub fn is_object_encryption_marker(key: &str) -> bool {
|
||||||
|
(is_encryption_metadata_key(key)
|
||||||
|
&& !key.eq_ignore_ascii_case(RUSTFS_ENCRYPTION_ORIGINAL_SIZE)
|
||||||
|
&& !key.eq_ignore_ascii_case(MINIO_ENCRYPTION_ORIGINAL_SIZE))
|
||||||
|
|| super::is_sse_header(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the logical object size recorded by encryption metadata.
|
||||||
|
pub fn get_object_encryption_original_size(metadata: &std::collections::HashMap<String, String>) -> std::io::Result<Option<i64>> {
|
||||||
|
let actual_size = super::get_str(metadata, super::SUFFIX_ACTUAL_SIZE);
|
||||||
|
let size = get_case_insensitive(metadata, RUSTFS_ENCRYPTION_ORIGINAL_SIZE)
|
||||||
|
.or_else(|| get_case_insensitive(metadata, SSEC_ORIGINAL_SIZE))
|
||||||
|
.or(actual_size.as_deref());
|
||||||
|
|
||||||
|
let Some(size) = size.filter(|size| !size.is_empty()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
size.parse::<i64>()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| std::io::Error::other(format!("Failed to parse encryption original size: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_case_insensitive<'a>(metadata: &'a std::collections::HashMap<String, String>, key: &str) -> Option<&'a str> {
|
||||||
|
metadata.get(key).map(String::as_str).or_else(|| {
|
||||||
|
metadata
|
||||||
|
.iter()
|
||||||
|
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
|
||||||
|
.map(|(_, value)| value.as_str())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rustfs_key(suffix: &str) -> String {
|
fn rustfs_key(suffix: &str) -> String {
|
||||||
@@ -106,10 +149,37 @@ mod tests {
|
|||||||
assert!(is_encryption_metadata_key("x-rustfs-encryption-iv"));
|
assert!(is_encryption_metadata_key("x-rustfs-encryption-iv"));
|
||||||
assert!(is_encryption_metadata_key("X-Rustfs-Encryption-Key"));
|
assert!(is_encryption_metadata_key("X-Rustfs-Encryption-Key"));
|
||||||
assert!(is_encryption_metadata_key("x-minio-encryption-iv"));
|
assert!(is_encryption_metadata_key("x-minio-encryption-iv"));
|
||||||
|
assert!(is_encryption_metadata_key("X-Minio-Internal-Server-Side-Encryption-Sealed-Key"));
|
||||||
|
assert!(is_encryption_metadata_key("X-Minio-Internal-Encrypted-Multipart"));
|
||||||
assert!(!is_encryption_metadata_key("x-amz-meta-custom"));
|
assert!(!is_encryption_metadata_key("x-amz-meta-custom"));
|
||||||
assert!(!is_encryption_metadata_key("x-rustfs-internal-healing"));
|
assert!(!is_encryption_metadata_key("x-rustfs-internal-healing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_encryption_marker_excludes_size_only_metadata() {
|
||||||
|
assert!(!is_object_encryption_marker(RUSTFS_ENCRYPTION_ORIGINAL_SIZE));
|
||||||
|
assert!(is_object_encryption_marker("X-Minio-Internal-Server-Side-Encryption-Sealed-Key"));
|
||||||
|
assert!(is_object_encryption_marker("x-amz-server-side-encryption"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_encryption_original_size_is_case_insensitive() {
|
||||||
|
let metadata = std::collections::HashMap::from([(
|
||||||
|
"X-Amz-Server-Side-Encryption-Customer-Original-Size".to_string(),
|
||||||
|
"42".to_string(),
|
||||||
|
)]);
|
||||||
|
assert_eq!(get_object_encryption_original_size(&metadata).expect("valid size"), Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_encryption_original_size_prefers_rustfs_metadata() {
|
||||||
|
let metadata = std::collections::HashMap::from([
|
||||||
|
(SSEC_ORIGINAL_SIZE.to_string(), "21".to_string()),
|
||||||
|
(RUSTFS_ENCRYPTION_ORIGINAL_SIZE.to_string(), "42".to_string()),
|
||||||
|
]);
|
||||||
|
assert_eq!(get_object_encryption_original_size(&metadata).expect("valid size"), Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_header() {
|
fn test_get_header() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ Fixture-backed tests should run when the fixture path is present:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture
|
cargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture
|
||||||
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored --nocapture
|
cargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
## Multi-Expert Adversarial Review Summary
|
## Multi-Expert Adversarial Review Summary
|
||||||
|
|||||||
@@ -393,36 +393,27 @@ impl Operation for ConfigureKmsHandler {
|
|||||||
// Convert request to KmsConfig
|
// Convert request to KmsConfig
|
||||||
let kms_config = configure_request.to_kms_config();
|
let kms_config = configure_request.to_kms_config();
|
||||||
|
|
||||||
// Configure the service
|
let persisted_config = kms_config.clone();
|
||||||
let (success, message, status) = match service_manager.configure(kms_config.clone()).await {
|
let (success, message, status) = match service_manager
|
||||||
|
.configure_with_persistence(kms_config, || async move {
|
||||||
|
save_kms_config(&persisted_config)
|
||||||
|
.await
|
||||||
|
.map_err(|error| rustfs_kms::KmsError::backend_error(format!("Failed to persist KMS configuration: {error}")))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Persist the configuration to cluster storage
|
let status = service_manager.get_status().await;
|
||||||
if let Err(e) = save_kms_config(&kms_config).await {
|
info!(
|
||||||
let error_msg = format!("KMS configured in memory but failed to persist: {e}");
|
component = LOG_COMPONENT_ADMIN,
|
||||||
error!(
|
subsystem = LOG_SUBSYSTEM_KMS,
|
||||||
component = LOG_COMPONENT_ADMIN,
|
event = "kms_service_state",
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
operation = "configure",
|
||||||
event = "kms_service_state",
|
state = "configured",
|
||||||
operation = "configure",
|
status = ?status,
|
||||||
state = "persist_failed",
|
"admin kms dynamic state"
|
||||||
error = %e,
|
);
|
||||||
"admin kms dynamic state"
|
(true, "KMS configured successfully".to_string(), status)
|
||||||
);
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
(false, error_msg, status)
|
|
||||||
} else {
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
info!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
|
||||||
event = "kms_service_state",
|
|
||||||
operation = "configure",
|
|
||||||
state = "configured",
|
|
||||||
status = ?status,
|
|
||||||
"admin kms dynamic state"
|
|
||||||
);
|
|
||||||
(true, "KMS configured successfully".to_string(), status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = format!("Failed to configure KMS: {e}");
|
let error_msg = format!("Failed to configure KMS: {e}");
|
||||||
@@ -529,125 +520,61 @@ impl Operation for StartKmsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let service_manager = kms_service_manager_from_context();
|
let service_manager = kms_service_manager_from_context();
|
||||||
|
let force = start_request.force.unwrap_or(false);
|
||||||
// Check if already running and force flag
|
let (success, message, status) = match service_manager.start_or_restart(force).await {
|
||||||
let current_status = service_manager.get_status().await;
|
Ok(rustfs_kms::KmsStartOutcome::Started) => {
|
||||||
if matches!(current_status, KmsServiceStatus::Running) && !start_request.force.unwrap_or(false) {
|
let status = service_manager.get_status().await;
|
||||||
warn!(
|
info!(
|
||||||
component = LOG_COMPONENT_ADMIN,
|
component = LOG_COMPONENT_ADMIN,
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
subsystem = LOG_SUBSYSTEM_KMS,
|
||||||
event = "kms_service_state",
|
event = "kms_service_state",
|
||||||
operation = "start",
|
operation = "start",
|
||||||
state = "already_running",
|
state = "running",
|
||||||
"admin kms dynamic state"
|
status = ?status,
|
||||||
);
|
"admin kms dynamic state"
|
||||||
let response = StartKmsResponse {
|
);
|
||||||
success: false,
|
(true, "KMS service started successfully".to_string(), status)
|
||||||
message: "KMS service is already running. Use force=true to restart.".to_string(),
|
}
|
||||||
status: current_status,
|
Ok(rustfs_kms::KmsStartOutcome::Restarted) => {
|
||||||
};
|
let status = service_manager.get_status().await;
|
||||||
let json_response = match serde_json::to_string(&response) {
|
info!(
|
||||||
Ok(json) => json,
|
component = LOG_COMPONENT_ADMIN,
|
||||||
Err(e) => {
|
subsystem = LOG_SUBSYSTEM_KMS,
|
||||||
error!(
|
event = "kms_service_state",
|
||||||
component = LOG_COMPONENT_ADMIN,
|
operation = "restart",
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
state = "running",
|
||||||
event = EVENT_ADMIN_KMS_DYNAMIC_STATE,
|
status = ?status,
|
||||||
operation = "start",
|
"admin kms dynamic state"
|
||||||
result = "response_serialize_failed",
|
);
|
||||||
error = %e,
|
(true, "KMS service restarted successfully".to_string(), status)
|
||||||
"admin kms dynamic state"
|
}
|
||||||
);
|
Ok(rustfs_kms::KmsStartOutcome::AlreadyRunning) => {
|
||||||
return Ok(S3Response::new((
|
let status = service_manager.get_status().await;
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
warn!(
|
||||||
Body::from("Serialization error".to_string()),
|
component = LOG_COMPONENT_ADMIN,
|
||||||
)));
|
subsystem = LOG_SUBSYSTEM_KMS,
|
||||||
}
|
event = "kms_service_state",
|
||||||
};
|
operation = "start",
|
||||||
return Ok(S3Response::new((StatusCode::OK, Body::from(json_response))));
|
state = "already_running",
|
||||||
}
|
"admin kms dynamic state"
|
||||||
|
);
|
||||||
// Start the service (or restart if force=true)
|
(false, "KMS service is already running. Use force=true to restart.".to_string(), status)
|
||||||
let (success, message, status) =
|
}
|
||||||
if start_request.force.unwrap_or(false) && matches!(current_status, KmsServiceStatus::Running) {
|
Err(e) => {
|
||||||
// Force restart
|
let error_msg = format!("Failed to start or restart KMS service: {e}");
|
||||||
match service_manager.stop().await {
|
error!(
|
||||||
Ok(()) => match service_manager.start().await {
|
component = LOG_COMPONENT_ADMIN,
|
||||||
Ok(()) => {
|
subsystem = LOG_SUBSYSTEM_KMS,
|
||||||
let status = service_manager.get_status().await;
|
event = "kms_service_state",
|
||||||
info!(
|
operation = "start",
|
||||||
component = LOG_COMPONENT_ADMIN,
|
state = "start_failed",
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
error = %e,
|
||||||
event = "kms_service_state",
|
"admin kms dynamic state"
|
||||||
operation = "restart",
|
);
|
||||||
state = "running",
|
let status = service_manager.get_status().await;
|
||||||
status = ?status,
|
(false, error_msg, status)
|
||||||
"admin kms dynamic state"
|
}
|
||||||
);
|
};
|
||||||
(true, "KMS service restarted successfully".to_string(), status)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let error_msg = format!("Failed to restart KMS service: {e}");
|
|
||||||
error!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
|
||||||
event = "kms_service_state",
|
|
||||||
operation = "restart",
|
|
||||||
state = "start_failed",
|
|
||||||
error = %e,
|
|
||||||
"admin kms dynamic state"
|
|
||||||
);
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
(false, error_msg, status)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
let error_msg = format!("Failed to stop KMS service for restart: {e}");
|
|
||||||
error!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
|
||||||
event = "kms_service_state",
|
|
||||||
operation = "restart",
|
|
||||||
state = "stop_failed",
|
|
||||||
error = %e,
|
|
||||||
"admin kms dynamic state"
|
|
||||||
);
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
(false, error_msg, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal start
|
|
||||||
match service_manager.start().await {
|
|
||||||
Ok(()) => {
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
info!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
|
||||||
event = "kms_service_state",
|
|
||||||
operation = "start",
|
|
||||||
state = "running",
|
|
||||||
status = ?status,
|
|
||||||
"admin kms dynamic state"
|
|
||||||
);
|
|
||||||
(true, "KMS service started successfully".to_string(), status)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let error_msg = format!("Failed to start KMS service: {e}");
|
|
||||||
error!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
|
||||||
event = "kms_service_state",
|
|
||||||
operation = "start",
|
|
||||||
state = "start_failed",
|
|
||||||
error = %e,
|
|
||||||
"admin kms dynamic state"
|
|
||||||
);
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
(false, error_msg, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = StartKmsResponse {
|
let response = StartKmsResponse {
|
||||||
success,
|
success,
|
||||||
@@ -804,8 +731,7 @@ impl Operation for GetKmsStatusHandler {
|
|||||||
|
|
||||||
let service_manager = kms_service_manager_from_context();
|
let service_manager = kms_service_manager_from_context();
|
||||||
|
|
||||||
let status = service_manager.get_status().await;
|
let (status, config) = service_manager.get_redacted_state().await;
|
||||||
let config = service_manager.get_redacted_config().await;
|
|
||||||
|
|
||||||
// Get backend type and health status
|
// Get backend type and health status
|
||||||
let backend_type = config.as_ref().map(|c| c.backend.clone());
|
let backend_type = config.as_ref().map(|c| c.backend.clone());
|
||||||
@@ -938,36 +864,27 @@ impl Operation for ReconfigureKmsHandler {
|
|||||||
// Convert request to KmsConfig
|
// Convert request to KmsConfig
|
||||||
let kms_config = configure_request.to_kms_config();
|
let kms_config = configure_request.to_kms_config();
|
||||||
|
|
||||||
// Reconfigure the service (stops, reconfigures, and starts)
|
let persisted_config = kms_config.clone();
|
||||||
let (success, message, status) = match service_manager.reconfigure(kms_config.clone()).await {
|
let (success, message, status) = match service_manager
|
||||||
|
.reconfigure_with_persistence(kms_config, || async move {
|
||||||
|
save_kms_config(&persisted_config)
|
||||||
|
.await
|
||||||
|
.map_err(|error| rustfs_kms::KmsError::backend_error(format!("Failed to persist KMS configuration: {error}")))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Persist the configuration to cluster storage
|
let status = service_manager.get_status().await;
|
||||||
if let Err(e) = save_kms_config(&kms_config).await {
|
info!(
|
||||||
let error_msg = format!("KMS reconfigured in memory but failed to persist: {e}");
|
component = LOG_COMPONENT_ADMIN,
|
||||||
error!(
|
subsystem = LOG_SUBSYSTEM_KMS,
|
||||||
component = LOG_COMPONENT_ADMIN,
|
event = "kms_service_state",
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
operation = "reconfigure",
|
||||||
event = "kms_service_state",
|
state = "reconfigured",
|
||||||
operation = "reconfigure",
|
status = ?status,
|
||||||
state = "persist_failed",
|
"admin kms dynamic state"
|
||||||
error = %e,
|
);
|
||||||
"admin kms dynamic state"
|
(true, "KMS reconfigured and restarted successfully".to_string(), status)
|
||||||
);
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
(false, error_msg, status)
|
|
||||||
} else {
|
|
||||||
let status = service_manager.get_status().await;
|
|
||||||
info!(
|
|
||||||
component = LOG_COMPONENT_ADMIN,
|
|
||||||
subsystem = LOG_SUBSYSTEM_KMS,
|
|
||||||
event = "kms_service_state",
|
|
||||||
operation = "reconfigure",
|
|
||||||
state = "reconfigured",
|
|
||||||
status = ?status,
|
|
||||||
"admin kms dynamic state"
|
|
||||||
);
|
|
||||||
(true, "KMS reconfigured and restarted successfully".to_string(), status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = format!("Failed to reconfigure KMS: {e}");
|
let error_msg = format!("Failed to reconfigure KMS: {e}");
|
||||||
|
|||||||
@@ -18,13 +18,12 @@ use super::handles::{
|
|||||||
IamHandle, KmsHandle, default_action_credential_interface, default_boot_time_interface, default_bucket_metadata_interface,
|
IamHandle, KmsHandle, default_action_credential_interface, default_boot_time_interface, default_bucket_metadata_interface,
|
||||||
default_bucket_monitor_interface, default_buffer_config_interface, default_deployment_id_interface,
|
default_bucket_monitor_interface, default_buffer_config_interface, default_deployment_id_interface,
|
||||||
default_endpoints_interface, default_expiry_state_interface, default_federated_identity_interface,
|
default_endpoints_interface, default_expiry_state_interface, default_federated_identity_interface,
|
||||||
default_internode_metrics_interface, default_kms_runtime_interface, default_local_node_name_interface,
|
default_internode_metrics_interface, default_local_node_name_interface, default_lock_client_interface,
|
||||||
default_lock_client_interface, default_lock_clients_interface, default_notification_system_interface,
|
default_lock_clients_interface, default_notification_system_interface, default_notify_interface,
|
||||||
default_notify_interface, default_outbound_tls_runtime_interface, default_performance_metrics_interface,
|
default_outbound_tls_runtime_interface, default_performance_metrics_interface, default_region_interface,
|
||||||
default_region_interface, default_replication_pool_interface, default_replication_stats_interface,
|
default_replication_pool_interface, default_replication_stats_interface, default_runtime_port_interface,
|
||||||
default_runtime_port_interface, default_s3select_db_interface, default_scanner_metrics_interface,
|
default_s3select_db_interface, default_scanner_metrics_interface, default_server_config_interface,
|
||||||
default_server_config_interface, default_storage_class_interface, default_tier_config_interface,
|
default_storage_class_interface, default_tier_config_interface, default_transition_state_interface,
|
||||||
default_transition_state_interface,
|
|
||||||
};
|
};
|
||||||
use super::interfaces::{
|
use super::interfaces::{
|
||||||
ActionCredentialInterface, BootTimeInterface, BucketMetadataInterface, BucketMonitorInterface, BufferConfigInterface,
|
ActionCredentialInterface, BootTimeInterface, BucketMetadataInterface, BucketMonitorInterface, BufferConfigInterface,
|
||||||
@@ -80,6 +79,7 @@ pub struct AppContext {
|
|||||||
impl AppContext {
|
impl AppContext {
|
||||||
pub fn new(object_store: Arc<ECStore>, iam: Arc<dyn IamInterface>, kms: Arc<dyn KmsInterface>) -> Self {
|
pub fn new(object_store: Arc<ECStore>, iam: Arc<dyn IamInterface>, kms: Arc<dyn KmsInterface>) -> Self {
|
||||||
let object_data_cache = ObjectDataCacheAdapter::from_env_or_disabled();
|
let object_data_cache = ObjectDataCacheAdapter::from_env_or_disabled();
|
||||||
|
let kms_runtime = Arc::new(crate::app::context::handles::KmsRuntimeHandle::new(kms.handle()));
|
||||||
// Let ecstore probe this cache inside get_object_reader, after
|
// Let ecstore probe this cache inside get_object_reader, after
|
||||||
// metadata resolution but before the erasure data read (backlog#802).
|
// metadata resolution but before the erasure data read (backlog#802).
|
||||||
crate::app::object_data_cache::register_object_data_cache_body_hook(Arc::clone(&object_data_cache));
|
crate::app::object_data_cache::register_object_data_cache_body_hook(Arc::clone(&object_data_cache));
|
||||||
@@ -94,7 +94,7 @@ impl AppContext {
|
|||||||
iam,
|
iam,
|
||||||
federated_identity: default_federated_identity_interface(),
|
federated_identity: default_federated_identity_interface(),
|
||||||
kms,
|
kms,
|
||||||
kms_runtime: default_kms_runtime_interface(),
|
kms_runtime,
|
||||||
outbound_tls_runtime: default_outbound_tls_runtime_interface(),
|
outbound_tls_runtime: default_outbound_tls_runtime_interface(),
|
||||||
notify: default_notify_interface(),
|
notify: default_notify_interface(),
|
||||||
notification_system: default_notification_system_interface(),
|
notification_system: default_notification_system_interface(),
|
||||||
|
|||||||
@@ -128,12 +128,19 @@ impl KmsInterface for KmsHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Default KMS runtime interface adapter.
|
/// Default KMS runtime interface adapter.
|
||||||
#[derive(Default)]
|
pub struct KmsRuntimeHandle {
|
||||||
pub struct KmsRuntimeHandle;
|
kms: Option<Arc<KmsServiceManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KmsRuntimeHandle {
|
||||||
|
pub fn new(kms: Arc<KmsServiceManager>) -> Self {
|
||||||
|
Self { kms: Some(kms) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl KmsRuntimeInterface for KmsRuntimeHandle {
|
impl KmsRuntimeInterface for KmsRuntimeHandle {
|
||||||
fn service_manager(&self) -> Option<Arc<KmsServiceManager>> {
|
fn service_manager(&self) -> Option<Arc<KmsServiceManager>> {
|
||||||
runtime_sources::kms_service_manager()
|
self.kms.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +492,9 @@ pub fn default_notification_system_interface() -> Arc<dyn NotificationSystemInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_kms_runtime_interface() -> Arc<dyn KmsRuntimeInterface> {
|
pub fn default_kms_runtime_interface() -> Arc<dyn KmsRuntimeInterface> {
|
||||||
Arc::new(KmsRuntimeHandle)
|
Arc::new(KmsRuntimeHandle {
|
||||||
|
kms: runtime_sources::kms_service_manager(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_outbound_tls_runtime_interface() -> Arc<dyn OutboundTlsRuntimeInterface> {
|
pub fn default_outbound_tls_runtime_interface() -> Arc<dyn OutboundTlsRuntimeInterface> {
|
||||||
@@ -607,10 +616,10 @@ pub fn default_buffer_config_interface() -> Arc<dyn BufferConfigInterface> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ServerConfigHandle, default_federated_identity_interface, federated_identity_interface,
|
KmsRuntimeHandle, KmsServiceManager, ServerConfigHandle, default_federated_identity_interface,
|
||||||
publish_default_federated_identity_service, runtime_sources,
|
federated_identity_interface, publish_default_federated_identity_service, runtime_sources,
|
||||||
};
|
};
|
||||||
use crate::app::context::interfaces::ServerConfigInterface;
|
use crate::app::context::interfaces::{KmsRuntimeInterface, ServerConfigInterface};
|
||||||
use rustfs_config::server_config::Config;
|
use rustfs_config::server_config::Config;
|
||||||
use rustfs_iam::{
|
use rustfs_iam::{
|
||||||
federation::{FederatedIdentityRegistry, FederatedIdentityService, oidc::StandardOidcAdapter},
|
federation::{FederatedIdentityRegistry, FederatedIdentityService, oidc::StandardOidcAdapter},
|
||||||
@@ -733,4 +742,19 @@ mod tests {
|
|||||||
"handle B must serve its own credentials"
|
"handle B must serve its own credentials"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kms_runtime_handles_keep_injected_managers_isolated() {
|
||||||
|
let manager_a = Arc::new(KmsServiceManager::new());
|
||||||
|
let manager_b = Arc::new(KmsServiceManager::new());
|
||||||
|
let handle_a = KmsRuntimeHandle::new(manager_a.clone());
|
||||||
|
let handle_b = KmsRuntimeHandle::new(manager_b.clone());
|
||||||
|
|
||||||
|
assert!(Arc::ptr_eq(&handle_a.service_manager().expect("manager A"), &manager_a));
|
||||||
|
assert!(Arc::ptr_eq(&handle_b.service_manager().expect("manager B"), &manager_b));
|
||||||
|
assert!(!Arc::ptr_eq(
|
||||||
|
&handle_a.service_manager().expect("manager A"),
|
||||||
|
&handle_b.service_manager().expect("manager B")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-9
@@ -4,14 +4,13 @@ use std::fs;
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
mod storage_api;
|
use super::sse::SseObjectEncryptionResolver;
|
||||||
|
use super::storage_api::ecstore_test_support::{
|
||||||
|
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
|
||||||
|
};
|
||||||
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
|
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use storage_api::minio_generated_read::{
|
|
||||||
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
|
|
||||||
};
|
|
||||||
use temp_env::async_with_vars;
|
use temp_env::async_with_vars;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWrite};
|
use tokio::io::{AsyncReadExt, AsyncWrite};
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ impl AsyncWrite for VecAsyncWriter {
|
|||||||
fn fixture_root() -> PathBuf {
|
fn fixture_root() -> PathBuf {
|
||||||
std::env::var_os("RUSTFS_MINIO_FIXTURE_ROOT")
|
std::env::var_os("RUSTFS_MINIO_FIXTURE_ROOT")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../rio-v2/tests/fixtures/minio-generated"))
|
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../crates/rio-v2/tests/fixtures/minio-generated"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn case_dir(case_id: &str) -> PathBuf {
|
fn case_dir(case_id: &str) -> PathBuf {
|
||||||
@@ -137,12 +136,14 @@ async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms
|
|||||||
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
|
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
|
||||||
],
|
],
|
||||||
async move {
|
async move {
|
||||||
let (mut reader, offset, length) = GetObjectReader::new(
|
let resolver = SseObjectEncryptionResolver;
|
||||||
|
let (mut reader, offset, length) = GetObjectReader::new_with_resolver(
|
||||||
Box::new(Cursor::new(encrypted)),
|
Box::new(Cursor::new(encrypted)),
|
||||||
None,
|
None,
|
||||||
&object_info,
|
&object_info,
|
||||||
&ObjectOptions::default(),
|
&ObjectOptions::default(),
|
||||||
&http::HeaderMap::new(),
|
&http::HeaderMap::new(),
|
||||||
|
Some(&resolver),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
|
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
|
||||||
@@ -219,11 +220,12 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
|
|||||||
readers.push(reader);
|
readers.push(reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
let erasure = Erasure::new(
|
let erasure = Erasure::try_new(
|
||||||
file_info.erasure.data_blocks,
|
file_info.erasure.data_blocks,
|
||||||
file_info.erasure.parity_blocks,
|
file_info.erasure.parity_blocks,
|
||||||
file_info.erasure.block_size,
|
file_info.erasure.block_size,
|
||||||
);
|
)
|
||||||
|
.expect("fixture erasure geometry");
|
||||||
let mut writer = VecAsyncWriter::default();
|
let mut writer = VecAsyncWriter::default();
|
||||||
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
|
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
|
||||||
if let Some(err) = err {
|
if let Some(err) = err {
|
||||||
@@ -36,6 +36,8 @@ mod ecfs_extend;
|
|||||||
mod ecfs_test;
|
mod ecfs_test;
|
||||||
pub(crate) mod head_prefix;
|
pub(crate) mod head_prefix;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod minio_generated_read_test;
|
||||||
|
#[cfg(test)]
|
||||||
mod multi_factor_scheduler_integration_test;
|
mod multi_factor_scheduler_integration_test;
|
||||||
pub(crate) mod runtime_sources;
|
pub(crate) mod runtime_sources;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+339
-40
@@ -70,6 +70,10 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use super::StorageError;
|
use super::StorageError;
|
||||||
|
use super::storage_api::ecstore_object::{
|
||||||
|
EncryptionResolutionError, EncryptionResolutionErrorKind, ObjectEncryptionResolver, ReadEncryptionMaterial,
|
||||||
|
ReadEncryptionMode, ReadEncryptionRequest,
|
||||||
|
};
|
||||||
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
|
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
use aes_gcm::aead::Payload;
|
use aes_gcm::aead::Payload;
|
||||||
@@ -155,6 +159,7 @@ use rustfs_utils::http::headers::{
|
|||||||
};
|
};
|
||||||
use rustfs_utils::path::path_join_buf;
|
use rustfs_utils::path::path_join_buf;
|
||||||
use s3s::dto::{SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSKeyId};
|
use s3s::dto::{SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSKeyId};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// High-Level SSE Configuration
|
// High-Level SSE Configuration
|
||||||
@@ -652,6 +657,23 @@ pub(crate) fn validate_sse_headers_for_read(metadata: &HashMap<String, String>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn map_get_object_reader_error(err: StorageError) -> ApiError {
|
pub(crate) fn map_get_object_reader_error(err: StorageError) -> ApiError {
|
||||||
|
if let StorageError::Io(io_error) = &err
|
||||||
|
&& let Some(resolution_error) = io_error
|
||||||
|
.get_ref()
|
||||||
|
.and_then(|source| source.downcast_ref::<EncryptionResolutionError>())
|
||||||
|
{
|
||||||
|
let code = match resolution_error.kind() {
|
||||||
|
EncryptionResolutionErrorKind::InvalidRequest => S3ErrorCode::InvalidRequest,
|
||||||
|
EncryptionResolutionErrorKind::ServiceUnavailable => S3ErrorCode::ServiceUnavailable,
|
||||||
|
_ => S3ErrorCode::InternalError,
|
||||||
|
};
|
||||||
|
return ApiError {
|
||||||
|
code,
|
||||||
|
message: resolution_error.to_string(),
|
||||||
|
source: Some(Box::new(err)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(message) = map_ssec_get_object_reader_error_message(&err) {
|
if let Some(message) = map_ssec_get_object_reader_error_message(&err) {
|
||||||
return ApiError {
|
return ApiError {
|
||||||
code: S3ErrorCode::InvalidRequest,
|
code: S3ErrorCode::InvalidRequest,
|
||||||
@@ -774,6 +796,117 @@ pub enum EncryptionKeyKind {
|
|||||||
Object,
|
Object,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SseObjectEncryptionResolver;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ObjectEncryptionResolver for SseObjectEncryptionResolver {
|
||||||
|
async fn resolve_read_material(
|
||||||
|
&self,
|
||||||
|
request: ReadEncryptionRequest<'_>,
|
||||||
|
) -> Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError> {
|
||||||
|
let metadata = normalize_encryption_metadata_case(request.metadata)?;
|
||||||
|
let (customer_algorithm, customer_key, customer_key_md5) =
|
||||||
|
extract_ssec_params_from_headers(request.headers).map_err(map_encryption_resolution_error)?;
|
||||||
|
if let Some(stored_algorithm) = metadata.get("x-amz-server-side-encryption-customer-algorithm") {
|
||||||
|
let request_algorithm = customer_algorithm.as_ref().ok_or_else(|| {
|
||||||
|
map_encryption_resolution_error(ssec_invalid_request(
|
||||||
|
"The object was stored using a form of Server Side Encryption. \
|
||||||
|
The correct parameters must be provided to retrieve the object.",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if stored_algorithm != request_algorithm.as_str() {
|
||||||
|
return Err(map_encryption_resolution_error(ssec_invalid_request(
|
||||||
|
"The provided encryption parameters did not match the ones used originally to encrypt the object.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let material = sse_decryption(DecryptionRequest {
|
||||||
|
bucket: request.bucket,
|
||||||
|
key: request.object,
|
||||||
|
metadata: &metadata,
|
||||||
|
sse_customer_key: customer_key.as_ref(),
|
||||||
|
sse_customer_key_md5: customer_key_md5.as_ref(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_encryption_resolution_error)?;
|
||||||
|
|
||||||
|
Ok(material.map(|material| ReadEncryptionMaterial {
|
||||||
|
key_bytes: material.key_bytes,
|
||||||
|
mode: match material.key_kind {
|
||||||
|
EncryptionKeyKind::Direct => ReadEncryptionMode::Direct {
|
||||||
|
base_nonce: material.base_nonce,
|
||||||
|
},
|
||||||
|
EncryptionKeyKind::Object => ReadEncryptionMode::Object,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_encryption_metadata_case(
|
||||||
|
metadata: &HashMap<String, String>,
|
||||||
|
) -> Result<Cow<'_, HashMap<String, String>>, EncryptionResolutionError> {
|
||||||
|
const CANONICAL_KEYS: &[&str] = &[
|
||||||
|
"x-amz-server-side-encryption",
|
||||||
|
"x-amz-server-side-encryption-aws-kms-key-id",
|
||||||
|
"x-amz-server-side-encryption-customer-algorithm",
|
||||||
|
"x-amz-server-side-encryption-customer-key-md5",
|
||||||
|
SSEC_ORIGINAL_SIZE_HEADER,
|
||||||
|
INTERNAL_ENCRYPTION_KEY_ID_HEADER,
|
||||||
|
INTERNAL_ENCRYPTION_KEY_HEADER,
|
||||||
|
INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
|
||||||
|
INTERNAL_ENCRYPTION_IV_HEADER,
|
||||||
|
"x-rustfs-encryption-context",
|
||||||
|
"x-rustfs-encryption-tag",
|
||||||
|
INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_IV_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
|
||||||
|
MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
|
||||||
|
];
|
||||||
|
|
||||||
|
let needs_normalization = metadata.keys().any(|key| {
|
||||||
|
CANONICAL_KEYS
|
||||||
|
.iter()
|
||||||
|
.any(|canonical| key != canonical && key.eq_ignore_ascii_case(canonical))
|
||||||
|
});
|
||||||
|
if !needs_normalization {
|
||||||
|
return Ok(Cow::Borrowed(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut normalized = metadata.clone();
|
||||||
|
for canonical in CANONICAL_KEYS {
|
||||||
|
let mut matching_values = metadata
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(key, value)| key.eq_ignore_ascii_case(canonical).then_some(value));
|
||||||
|
let Some(value) = matching_values.next() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matching_values.any(|candidate| candidate != value) {
|
||||||
|
return Err(EncryptionResolutionError::new(
|
||||||
|
EncryptionResolutionErrorKind::InvalidMetadata,
|
||||||
|
format!("conflicting object encryption metadata for {canonical}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !normalized.contains_key(*canonical) {
|
||||||
|
normalized.insert((*canonical).to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Cow::Owned(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_encryption_resolution_error(error: ApiError) -> EncryptionResolutionError {
|
||||||
|
let kind = match error.code {
|
||||||
|
S3ErrorCode::InvalidArgument | S3ErrorCode::InvalidRequest => EncryptionResolutionErrorKind::InvalidRequest,
|
||||||
|
S3ErrorCode::ServiceUnavailable => EncryptionResolutionErrorKind::ServiceUnavailable,
|
||||||
|
_ => EncryptionResolutionErrorKind::DecryptionFailed,
|
||||||
|
};
|
||||||
|
EncryptionResolutionError::new(kind, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ManagedSealedKey {
|
pub struct ManagedSealedKey {
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
@@ -2642,19 +2775,20 @@ fn ssec_invalid_request(message: &str) -> ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest,
|
ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest,
|
||||||
INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER,
|
EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER,
|
||||||
INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsSseDekProvider, KmsUnavailableError, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
|
INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsSseDekProvider, KmsUnavailableError,
|
||||||
MINIO_INTERNAL_ENCRYPTION_IV_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
|
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER,
|
||||||
MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER,
|
MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
|
||||||
MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
|
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER,
|
||||||
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, PrepareEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, SSEType,
|
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
|
||||||
SseDekProvider, SsecParams, StorageError, TestSseDekProvider, apply_managed_decryption_material,
|
ObjectEncryptionResolver, PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER,
|
||||||
apply_managed_encryption_material, encryption_material_to_metadata, extract_server_side_encryption_from_headers,
|
SSEType, SseDekProvider, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider,
|
||||||
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, generate_ssec_nonce, is_managed_sse,
|
apply_managed_decryption_material, apply_managed_encryption_material, encryption_material_to_metadata,
|
||||||
kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata, md5_base64,
|
extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
|
||||||
normalize_managed_metadata, reset_sse_dek_provider, resolve_effective_kms_key_id, sse_decryption, sse_encryption,
|
generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata,
|
||||||
sse_prepare_encryption, strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write,
|
md5_base64, normalize_managed_metadata, reset_sse_dek_provider, resolve_effective_kms_key_id, sse_decryption,
|
||||||
validate_ssec_for_read, validate_ssec_params, verify_ssec_key_match,
|
sse_encryption, sse_prepare_encryption, strip_managed_encryption_metadata, validate_sse_headers_for_read,
|
||||||
|
validate_sse_headers_for_write, validate_ssec_for_read, validate_ssec_params, verify_ssec_key_match,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
use super::{
|
use super::{
|
||||||
@@ -2709,11 +2843,154 @@ mod tests {
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
static SSE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static SSE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
static SSE_TEST_KMS_KEY_DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||||
|
|
||||||
async fn lock_sse_test_state() -> tokio::sync::MutexGuard<'static, ()> {
|
async fn lock_sse_test_state() -> tokio::sync::MutexGuard<'static, ()> {
|
||||||
SSE_TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await
|
SSE_TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn configure_test_global_local_kms() -> Arc<rustfs_kms::KmsServiceManager> {
|
||||||
|
let key_dir = SSE_TEST_KMS_KEY_DIR.get_or_init(|| tempfile::TempDir::new().expect("create KMS key directory"));
|
||||||
|
let manager = rustfs_kms::init_global_kms_service_manager();
|
||||||
|
manager
|
||||||
|
.reconfigure(rustfs_kms::KmsConfig::local(key_dir.path().to_path_buf()).with_insecure_development_defaults())
|
||||||
|
.await
|
||||||
|
.expect("configure test KMS service");
|
||||||
|
manager
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_encryption_resolver_returns_ssec_read_material() {
|
||||||
|
let key = [0x31; 32];
|
||||||
|
let key_b64 = BASE64_STANDARD.encode(key);
|
||||||
|
let key_md5 = md5_base64(key);
|
||||||
|
let nonce = [0x42; 12];
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
("X-Amz-Server-Side-Encryption-Customer-Algorithm".to_string(), "AES256".to_string()),
|
||||||
|
("X-Amz-Server-Side-Encryption-Customer-Key-Md5".to_string(), key_md5.clone()),
|
||||||
|
("X-Rustfs-Encryption-Iv".to_string(), BASE64_STANDARD.encode(nonce)),
|
||||||
|
]);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static("AES256"));
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-server-side-encryption-customer-key",
|
||||||
|
HeaderValue::from_str(&key_b64).expect("base64 key is a valid header"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-server-side-encryption-customer-key-md5",
|
||||||
|
HeaderValue::from_str(&key_md5).expect("base64 MD5 is a valid header"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let material = SseObjectEncryptionResolver
|
||||||
|
.resolve_read_material(ReadEncryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
object: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
headers: &headers,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("SSE-C material should resolve")
|
||||||
|
.expect("SSE-C metadata should produce material");
|
||||||
|
|
||||||
|
assert_eq!(material.key_bytes, key);
|
||||||
|
assert_eq!(material.mode, ReadEncryptionMode::Direct { base_nonce: nonce });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_encryption_resolver_rejects_missing_or_invalid_ssec_algorithm() {
|
||||||
|
let key = [0x31; 32];
|
||||||
|
let key_b64 = BASE64_STANDARD.encode(key);
|
||||||
|
let key_md5 = md5_base64(key);
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||||
|
("x-amz-server-side-encryption-customer-key-md5".to_string(), key_md5.clone()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for algorithm in [None, Some("AES128")] {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
if let Some(algorithm) = algorithm {
|
||||||
|
headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static(algorithm));
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-server-side-encryption-customer-key",
|
||||||
|
HeaderValue::from_str(&key_b64).expect("base64 key is a valid header"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-server-side-encryption-customer-key-md5",
|
||||||
|
HeaderValue::from_str(&key_md5).expect("base64 MD5 is a valid header"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = SseObjectEncryptionResolver
|
||||||
|
.resolve_read_material(ReadEncryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
object: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
headers: &headers,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let error = match result {
|
||||||
|
Err(error) => error,
|
||||||
|
Ok(_) => panic!("missing or invalid SSE-C algorithm must fail closed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_encryption_resolver_classifies_missing_ssec_key_as_invalid_request() {
|
||||||
|
let metadata = HashMap::from([("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string())]);
|
||||||
|
let result = SseObjectEncryptionResolver
|
||||||
|
.resolve_read_material(ReadEncryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
object: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
headers: &HeaderMap::new(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let error = match result {
|
||||||
|
Err(error) => error,
|
||||||
|
Ok(_) => panic!("missing SSE-C key must fail closed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_encryption_resolver_rejects_conflicting_metadata_case_variants() {
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
("x-rustfs-encryption-key".to_string(), "first".to_string()),
|
||||||
|
("X-Rustfs-Encryption-Key".to_string(), "second".to_string()),
|
||||||
|
]);
|
||||||
|
let result = SseObjectEncryptionResolver
|
||||||
|
.resolve_read_material(ReadEncryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
object: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
headers: &HeaderMap::new(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let error = match result {
|
||||||
|
Err(error) => error,
|
||||||
|
Ok(_) => panic!("conflicting metadata aliases must fail closed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_encryption_metadata_case_accepts_lowercase_minio_internal_keys() {
|
||||||
|
let lowercase_key = MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_ascii_lowercase();
|
||||||
|
let metadata = HashMap::from([(lowercase_key, "sealed-key".to_string())]);
|
||||||
|
|
||||||
|
let normalized = super::normalize_encryption_metadata_case(&metadata).expect("metadata aliases should normalize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
normalized.get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER),
|
||||||
|
Some(&"sealed-key".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
struct UnavailableSseDekProvider;
|
struct UnavailableSseDekProvider;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -3433,18 +3710,11 @@ mod tests {
|
|||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
|
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
|
||||||
use rustfs_kms::config::KmsConfig;
|
|
||||||
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
||||||
use tempfile::TempDir;
|
|
||||||
let _guard = lock_sse_test_state().await;
|
let _guard = lock_sse_test_state().await;
|
||||||
|
|
||||||
reset_sse_dek_provider();
|
reset_sse_dek_provider();
|
||||||
let manager = rustfs_kms::init_global_kms_service_manager();
|
let manager = configure_test_global_local_kms().await;
|
||||||
let temp_dir = TempDir::new().expect("temp dir");
|
|
||||||
manager
|
|
||||||
.reconfigure(KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
||||||
.await
|
|
||||||
.expect("kms reconfigure should succeed");
|
|
||||||
manager
|
manager
|
||||||
.get_encryption_service()
|
.get_encryption_service()
|
||||||
.await
|
.await
|
||||||
@@ -3736,6 +4006,19 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
|
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
|
||||||
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
||||||
|
|
||||||
|
let resolved = SseObjectEncryptionResolver
|
||||||
|
.resolve_read_material(ReadEncryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
object: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
headers: &HeaderMap::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("managed resolver")
|
||||||
|
.expect("managed material");
|
||||||
|
assert_eq!(resolved.mode, ReadEncryptionMode::Object);
|
||||||
|
assert_eq!(resolved.key_bytes, material.key_bytes);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -3796,6 +4079,29 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
|
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
|
||||||
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static("AES256"));
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-server-side-encryption-customer-key",
|
||||||
|
HeaderValue::from_str(&customer_key).expect("customer key header"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-server-side-encryption-customer-key-md5",
|
||||||
|
HeaderValue::from_str(&customer_key_md5).expect("customer key MD5 header"),
|
||||||
|
);
|
||||||
|
let resolved = SseObjectEncryptionResolver
|
||||||
|
.resolve_read_material(ReadEncryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
object: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
headers: &headers,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("SSE-C resolver")
|
||||||
|
.expect("SSE-C material");
|
||||||
|
assert_eq!(resolved.mode, ReadEncryptionMode::Object);
|
||||||
|
assert_eq!(resolved.key_bytes, material.key_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
@@ -4270,17 +4576,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_managed_decryption_selects_provider_from_persisted_dek() {
|
async fn test_managed_decryption_selects_provider_from_persisted_dek() {
|
||||||
use rustfs_kms::config::KmsConfig;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let _guard = lock_sse_test_state().await;
|
let _guard = lock_sse_test_state().await;
|
||||||
reset_sse_dek_provider();
|
reset_sse_dek_provider();
|
||||||
let manager = rustfs_kms::init_global_kms_service_manager();
|
let manager = configure_test_global_local_kms().await;
|
||||||
let key_dir = TempDir::new().expect("create KMS key directory");
|
|
||||||
manager
|
|
||||||
.reconfigure(KmsConfig::local(key_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
||||||
.await
|
|
||||||
.expect("start test KMS service");
|
|
||||||
|
|
||||||
let local_master_key = [7u8; 32];
|
let local_master_key = [7u8; 32];
|
||||||
let local_provider = TestSseDekProvider::new_with_key(local_master_key);
|
let local_provider = TestSseDekProvider::new_with_key(local_master_key);
|
||||||
@@ -4348,9 +4646,6 @@ mod tests {
|
|||||||
/// the local-provider cache.
|
/// the local-provider cache.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_kms_envelope_never_routes_to_cached_local_provider() {
|
async fn test_kms_envelope_never_routes_to_cached_local_provider() {
|
||||||
use rustfs_kms::config::KmsConfig;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
let _guard = lock_sse_test_state().await;
|
let _guard = lock_sse_test_state().await;
|
||||||
reset_sse_dek_provider();
|
reset_sse_dek_provider();
|
||||||
|
|
||||||
@@ -4362,12 +4657,7 @@ mod tests {
|
|||||||
.expect("write local provider into local cache") = Some(Arc::new(TestSseDekProvider::new_with_key(local_master_key)));
|
.expect("write local provider into local cache") = Some(Arc::new(TestSseDekProvider::new_with_key(local_master_key)));
|
||||||
|
|
||||||
// 2. Start a KMS service (dynamic enable).
|
// 2. Start a KMS service (dynamic enable).
|
||||||
let manager = rustfs_kms::init_global_kms_service_manager();
|
let manager = configure_test_global_local_kms().await;
|
||||||
let key_dir = TempDir::new().expect("create KMS key directory");
|
|
||||||
manager
|
|
||||||
.reconfigure(KmsConfig::local(key_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
||||||
.await
|
|
||||||
.expect("start test KMS service");
|
|
||||||
|
|
||||||
// 3. Construct a KMS JSON envelope — the persisted format of a KMS-wrapped DEK.
|
// 3. Construct a KMS JSON envelope — the persisted format of a KMS-wrapped DEK.
|
||||||
// is_data_key_envelope() will return true for this payload.
|
// is_data_key_envelope() will return true for this payload.
|
||||||
@@ -4458,7 +4748,7 @@ mod tests {
|
|||||||
use rustfs_kms::config::KmsConfig;
|
use rustfs_kms::config::KmsConfig;
|
||||||
let _guard = lock_sse_test_state().await;
|
let _guard = lock_sse_test_state().await;
|
||||||
|
|
||||||
let manager = rustfs_kms::init_global_kms_service_manager();
|
let manager = Arc::new(rustfs_kms::KmsServiceManager::new());
|
||||||
|
|
||||||
manager
|
manager
|
||||||
.reconfigure(KmsConfig::static_kms("first-key".to_string(), BASE64_STANDARD.encode([0x11; 32])))
|
.reconfigure(KmsConfig::static_kms("first-key".to_string(), BASE64_STANDARD.encode([0x11; 32])))
|
||||||
@@ -4692,6 +4982,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_get_object_reader_error_preserves_typed_service_unavailable() {
|
||||||
|
let resolution_error =
|
||||||
|
super::EncryptionResolutionError::new(EncryptionResolutionErrorKind::ServiceUnavailable, "KMS unavailable");
|
||||||
|
let err = map_get_object_reader_error(StorageError::other(resolution_error));
|
||||||
|
assert_eq!(err.code, S3ErrorCode::ServiceUnavailable);
|
||||||
|
assert_eq!(err.message, "KMS unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_get_object_reader_error_leaves_non_ssec_errors_unchanged() {
|
fn test_map_get_object_reader_error_leaves_non_ssec_errors_unchanged() {
|
||||||
let err = map_get_object_reader_error(StorageError::other("plain io failure"));
|
let err = map_get_object_reader_error(StorageError::other("plain io failure"));
|
||||||
|
|||||||
@@ -509,12 +509,21 @@ pub(crate) mod ecstore_object {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::object::GetObjectBodySource;
|
pub(crate) use rustfs_ecstore::api::object::GetObjectBodySource;
|
||||||
pub(crate) use rustfs_ecstore::api::object::{
|
pub(crate) use rustfs_ecstore::api::object::{
|
||||||
GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, ObjectMutationHook, get_object_body_cache_plaintext_len,
|
EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup,
|
||||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
ObjectEncryptionResolver, ObjectMutationHook, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||||
|
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "rio-v2"))]
|
||||||
|
pub(crate) mod ecstore_test_support {
|
||||||
|
pub(crate) use rustfs_ecstore::api::bitrot::create_bitrot_reader;
|
||||||
|
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, endpoint::Endpoint, new_disk};
|
||||||
|
pub(crate) use rustfs_ecstore::api::erasure::Erasure;
|
||||||
|
pub(crate) use rustfs_ecstore::api::object::{GetObjectReader, ObjectInfo, ObjectOptions};
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) mod ecstore_set_disk {
|
pub(crate) mod ecstore_set_disk {
|
||||||
pub(crate) use rustfs_ecstore::api::set_disk::{DEFAULT_READ_BUFFER_SIZE, get_lock_acquire_timeout, is_valid_storage_class};
|
pub(crate) use rustfs_ecstore::api::set_disk::{DEFAULT_READ_BUFFER_SIZE, get_lock_acquire_timeout, is_valid_storage_class};
|
||||||
}
|
}
|
||||||
@@ -945,13 +954,21 @@ pub(crate) async fn init_local_disks(endpoint_pools: EndpointServerPools) -> Res
|
|||||||
/// The process-level bootstrap instance context that single-instance startup
|
/// The process-level bootstrap instance context that single-instance startup
|
||||||
/// threads through the storage foundation (Phase 5 follow-up, backlog#1052).
|
/// threads through the storage foundation (Phase 5 follow-up, backlog#1052).
|
||||||
pub(crate) fn bootstrap_instance_ctx() -> Arc<InstanceContext> {
|
pub(crate) fn bootstrap_instance_ctx() -> Arc<InstanceContext> {
|
||||||
ecstore_runtime::bootstrap_ctx()
|
let context = ecstore_runtime::bootstrap_ctx();
|
||||||
|
configure_object_encryption_resolver(&context);
|
||||||
|
context
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construct a fresh per-server instance context (backlog#1052 S5): a second
|
/// Construct a fresh per-server instance context (backlog#1052 S5): a second
|
||||||
/// embedded server owns its own erasure/region/endpoint/deployment id cells.
|
/// embedded server owns its own erasure/region/endpoint/deployment id cells.
|
||||||
pub(crate) fn new_instance_ctx() -> Arc<InstanceContext> {
|
pub(crate) fn new_instance_ctx() -> Arc<InstanceContext> {
|
||||||
Arc::new(InstanceContext::new())
|
let context = Arc::new(InstanceContext::new());
|
||||||
|
configure_object_encryption_resolver(&context);
|
||||||
|
context
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_object_encryption_resolver(context: &InstanceContext) {
|
||||||
|
let _ = context.set_object_encryption_resolver(Arc::new(super::sse::SseObjectEncryptionResolver));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn init_lock_clients(endpoint_pools: EndpointServerPools) {
|
pub(crate) fn init_lock_clients(endpoint_pools: EndpointServerPools) {
|
||||||
@@ -1746,7 +1763,7 @@ pub(crate) async fn init_compression_total_memory_from_backend(store: Arc<ECStor
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
apply_active_resync_intents, bucket_targets_metadata_lock_shard, ecstore_bucket, lock_bucket_targets_metadata,
|
apply_active_resync_intents, bucket_targets_metadata_lock_shard, ecstore_bucket, lock_bucket_targets_metadata,
|
||||||
scanner_maintenance_config_file,
|
new_instance_ctx, scanner_maintenance_config_file,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -1777,6 +1794,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_instance_context_installs_object_encryption_resolver() {
|
||||||
|
assert!(new_instance_ctx().object_encryption_resolver().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_instance_context_installs_object_encryption_resolver() {
|
||||||
|
assert!(super::bootstrap_instance_ctx().object_encryption_resolver().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_maintenance_config_only_includes_scanner_owned_work() {
|
fn scanner_maintenance_config_only_includes_scanner_owned_work() {
|
||||||
assert!(scanner_maintenance_config_file(ecstore_bucket::metadata::BUCKET_LIFECYCLE_CONFIG));
|
assert!(scanner_maintenance_config_file(ecstore_bucket::metadata::BUCKET_LIFECYCLE_CONFIG));
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ write_blackbox_matrix() {
|
|||||||
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tcargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\theal degraded erasure disk rebuild\tblack-box\tcargo test --package e2e_test heal_erasure_disk_rebuild_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tcargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
printf 'quick\tnamespace lock quorum under EC ops\tblack-box\tcargo test --package e2e_test namespace_lock_quorum_test -- --nocapture\tnone\t%s\n' "$e2e_status"
|
||||||
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
|
printf 'full\tlegacy bitrot read fixture restore\tfixture\tcargo test -p rustfs-ecstore --test legacy_bitrot_read_test -- --nocapture\tRUSTFS_LEGACY_TEST_ROOT,RUSTFS_LEGACY_TEST_DISK\t%s\n' "$legacy_status"
|
||||||
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
|
printf 'full\tMinIO generated encrypted read and negative restore fixture\tfixture\tcargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture\tRUSTFS_MINIO_FIXTURE_ROOT,RUSTFS_MINIO_STATIC_KMS_KEY_B64\t%s\n' "$minio_status"
|
||||||
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
|
printf 'full\tS3 multipart range versioning delete subset\tblack-box\tenv TESTEXPR=\"multipart or range or versioning or delete\" DEPLOY_MODE=build MAXFAIL=0 ./scripts/s3-tests/run.sh\tnone\t%s\n' "$s3_status"
|
||||||
printf 'destructive\tdistributed cluster concurrency\tblack-box\tcargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tdistributed cluster concurrency\tblack-box\tcargo test --package e2e_test cluster_concurrency_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tcargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
printf 'destructive\tstale multipart cleanup cluster\tblack-box\tcargo test --package e2e_test stale_multipart_cleanup_cluster_test -- --nocapture\tnone\t%s\n' "$destructive_status"
|
||||||
@@ -377,7 +377,7 @@ run_fixture_steps() {
|
|||||||
|
|
||||||
if fixture_available; then
|
if fixture_available; then
|
||||||
run_step "ecstore-minio-generated-read-fixture" \
|
run_step "ecstore-minio-generated-read-fixture" \
|
||||||
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored --nocapture
|
cargo test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored --nocapture
|
||||||
elif [[ "$REQUIRE_FIXTURES" == "true" ]]; then
|
elif [[ "$REQUIRE_FIXTURES" == "true" ]]; then
|
||||||
echo "ERROR: $(minio_fixture_missing_reason)" >&2
|
echo "ERROR: $(minio_fixture_missing_reason)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user