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

This commit is contained in:
weisd
2026-03-24 17:29:33 +08:00
committed by GitHub
parent 8aa59b12cb
commit 28f57b228c
60 changed files with 15509 additions and 412 deletions
+38
View File
@@ -57,7 +57,9 @@ struct StoredMasterKey {
status: KeyStatus,
description: Option<String>,
metadata: HashMap<String, String>,
#[serde(with = "crate::time_serde::zoned")]
created_at: Zoned,
#[serde(with = "crate::time_serde::option_zoned")]
rotated_at: Option<Zoned>,
created_by: Option<String>,
/// Encrypted key material (32 bytes encoded in base64 for AES-256)
@@ -840,6 +842,7 @@ impl KmsBackend for LocalKmsBackend {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
async fn create_test_client() -> (LocalKmsClient, TempDir) {
@@ -943,4 +946,39 @@ mod tests {
// Note: Direct decryption of encrypt() results is not implemented in this simple version
// In a real implementation, encrypt() would create a different envelope format
}
#[tokio::test]
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = LocalConfig {
key_dir: temp_dir.path().to_path_buf(),
master_key: None,
file_permissions: Some(0o600),
};
let client = LocalKmsClient::new(config).await.expect("Failed to create client");
let stored_key = serde_json::json!({
"key_id": "legacy-key",
"version": 1u32,
"algorithm": "AES_256",
"usage": "EncryptDecrypt",
"status": "Active",
"description": serde_json::Value::Null,
"metadata": HashMap::<String, String>::new(),
"created_at": "2024-01-01T00:00:00+00:00",
"rotated_at": serde_json::Value::Null,
"created_by": "legacy-test",
"encrypted_key_material": BASE64.encode([7u8; 32]),
"nonce": Vec::<u8>::new()
});
let key_path = client.master_key_path("legacy-key");
fs::write(&key_path, serde_json::to_vec_pretty(&stored_key).expect("serialize test key"))
.await
.expect("write legacy key");
let key_info = client.load_master_key("legacy-key").await.expect("legacy key should load");
assert_eq!(key_info.key_id, "legacy-key");
assert_eq!(key_info.created_at.time_zone().iana_name(), Some("UTC"));
}
}
+18
View File
@@ -40,6 +40,7 @@ pub struct DataKeyEnvelope {
pub encrypted_key: Vec<u8>,
pub nonce: Vec<u8>,
pub encryption_context: HashMap<String, String>,
#[serde(with = "crate::time_serde::zoned")]
pub created_at: Zoned,
}
@@ -311,4 +312,21 @@ mod tests {
assert_eq!(deserialized.key_id, "test-key-id");
assert_eq!(deserialized.master_key_id, "master-key-id");
}
#[tokio::test]
async fn test_data_key_envelope_accepts_legacy_rfc3339_timestamp() {
let envelope_json = r#"{
"key_id": "test-key-id",
"master_key_id": "master-key-id",
"key_spec": "AES_256",
"encrypted_key": [1, 2, 3, 4],
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"encryption_context": {"bucket": "test-bucket"},
"created_at": "2024-01-01T00:00:00+00:00"
}"#;
let deserialized: DataKeyEnvelope = serde_json::from_str(envelope_json).expect("Should deserialize legacy format");
assert_eq!(deserialized.key_id, "test-key-id");
assert_eq!(deserialized.master_key_id, "master-key-id");
}
}
+1
View File
@@ -65,6 +65,7 @@ mod error;
pub mod manager;
pub mod service;
pub mod service_manager;
mod time_serde;
pub mod types;
// Re-export public API
+13 -2
View File
@@ -49,6 +49,8 @@ pub struct ObjectEncryptionService {
kms_manager: KmsManager,
}
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
/// Result of object encryption
#[derive(Debug, Clone)]
pub struct EncryptionResult {
@@ -604,12 +606,13 @@ impl ObjectEncryptionService {
headers.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
} else if metadata.algorithm == "AES256" {
headers.insert("x-amz-server-side-encryption".to_string(), "AES256".to_string());
// For SSE-S3, we still need to store the key ID for internal use
headers.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), metadata.key_id.clone());
} else {
headers.insert("x-amz-server-side-encryption".to_string(), "aws:kms".to_string());
headers.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), metadata.key_id.clone());
}
if metadata.key_id != "sse-c" {
headers.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), metadata.key_id.clone());
}
// Internal headers for decryption
headers.insert(
@@ -653,8 +656,14 @@ impl ObjectEncryptionService {
let key_id = if algorithm == "AES256" && headers.contains_key("x-amz-server-side-encryption-customer-algorithm") {
"sse-c".to_string()
} else if let Some(key_id) = headers.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) {
key_id.clone()
} else if let Some(kms_key_id) = headers.get("x-amz-server-side-encryption-aws-kms-key-id") {
kms_key_id.clone()
} else if algorithm == "AES256" {
self.get_default_key_id()
.cloned()
.ok_or_else(|| KmsError::validation_error("Missing key ID"))?
} else {
return Err(KmsError::validation_error("Missing key ID"));
};
@@ -821,6 +830,8 @@ mod tests {
let headers = service.metadata_to_headers(&metadata);
assert!(headers.contains_key("x-amz-server-side-encryption"));
assert!(headers.contains_key("x-rustfs-encryption-iv"));
assert!(headers.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER));
assert!(!headers.contains_key("x-amz-server-side-encryption-aws-kms-key-id"));
// Convert back to metadata
let parsed_metadata = service.headers_to_metadata(&headers).expect("Failed to parse headers");
+73
View File
@@ -0,0 +1,73 @@
use jiff::{Timestamp, Zoned, tz::TimeZone};
use serde::{Deserialize, Deserializer, Serializer};
pub(crate) mod zoned {
use super::*;
pub(crate) fn serialize<S>(value: &Zoned, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_string())
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Zoned, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
parse_zoned_compat(&value).map_err(serde::de::Error::custom)
}
}
pub(crate) mod option_zoned {
use super::*;
pub(crate) fn serialize<S>(value: &Option<Zoned>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_some(&value.to_string()),
None => serializer.serialize_none(),
}
}
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Zoned>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|value| parse_zoned_compat(&value).map_err(serde::de::Error::custom))
.transpose()
}
}
fn parse_zoned_compat(value: &str) -> Result<Zoned, String> {
if let Ok(zoned) = value.parse::<Zoned>() {
return Ok(zoned);
}
let timestamp = value
.parse::<Timestamp>()
.map_err(|err| format!("failed to parse legacy timestamp '{value}': {err}"))?;
Ok(timestamp.to_zoned(TimeZone::UTC))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_zoned_compat_accepts_current_zoned_format() {
let zoned = parse_zoned_compat("2024-01-01T00:00:00+00:00[UTC]").expect("current format should parse");
assert_eq!(zoned.time_zone().iana_name(), Some("UTC"));
}
#[test]
fn parse_zoned_compat_accepts_legacy_rfc3339_format() {
let zoned = parse_zoned_compat("2024-01-01T00:00:00+00:00").expect("legacy format should parse");
assert_eq!(zoned.time_zone().iana_name(), Some("UTC"));
}
}