// 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. //! Black-box behavior: the wire and on-disk serialization contracts. //! //! These names outlive the process that wrote them. Object encryption headers //! sit in `xl.meta` for the life of an object; persisted `KmsConfig` documents //! are read back by later versions; backup manifests must stay decodable by a //! future restore. Renaming any of them is a compatibility break that no //! behavioral test would catch, because a fresh write and a fresh read agree //! with each other perfectly. //! //! Two techniques, deliberately: //! //! * **Enum wire spellings are asserted directly.** They are short, they are //! the highest-risk rename, and an explicit `assert_eq!` documents the //! intended spelling at the point of the check. //! * **Struct shapes are snapshotted as sorted field-name lists**, not as //! values. Values contain UUIDs, timestamps, and random key material, so a //! value snapshot would be unstable; the field set is exactly the part that //! constitutes the contract, and a change to it should be reviewed. mod common; use std::collections::BTreeSet; use common::{STATIC_KEY_ID, TestKms, static_secret_key}; use rustfs_kms::backup::{AeadAlgorithm, ArtifactKind, CompletenessState, DigestAlgorithm}; use rustfs_kms::{EncryptionAlgorithm, KeySpec, KeyState, KeyStatus, KeyUsage, KmsBackend, KmsConfig, KmsServiceStatus}; use serde::Serialize; /// Sorted top-level field names of a value's JSON object form. fn field_names(value: &T) -> Vec { let json = serde_json::to_value(value).expect("value should serialize"); match json { serde_json::Value::Object(map) => map.keys().cloned().collect::>().into_iter().collect(), other => panic!("expected a JSON object, got {other}"), } } fn wire(value: &T) -> String { serde_json::to_string(value).expect("value should serialize") } /// The tags a persisted document is matched on. A rename here silently /// orphans every previously written record, so each spelling is stated /// explicitly rather than snapshotted. #[test] fn enum_wire_spellings_are_stable() { // Backend discriminators appear in persisted KMS configuration. assert_eq!(wire(&KmsBackend::Local), r#""Local""#); assert_eq!(wire(&KmsBackend::Static), r#""Static""#); assert_eq!(wire(&KmsBackend::VaultKv2), r#""VaultKV2""#); assert_eq!(wire(&KmsBackend::VaultTransit), r#""VaultTransit""#); // The pre-rename spelling must still deserialize. assert_eq!( serde_json::from_str::(r#""Vault""#).expect("legacy label"), KmsBackend::VaultKv2, "the legacy `Vault` label must keep deserializing to VaultKV2" ); // Key lifecycle vocabulary, persisted in key records and returned by the // admin API. assert_eq!(wire(&KeyState::Enabled), r#""Enabled""#); assert_eq!(wire(&KeyState::Disabled), r#""Disabled""#); assert_eq!(wire(&KeyState::PendingDeletion), r#""PendingDeletion""#); assert_eq!(wire(&KeyState::PendingImport), r#""PendingImport""#); assert_eq!(wire(&KeyState::Unavailable), r#""Unavailable""#); assert_eq!(wire(&KeyStatus::Active), r#""Active""#); assert_eq!(wire(&KeyStatus::Disabled), r#""Disabled""#); assert_eq!(wire(&KeyStatus::PendingDeletion), r#""PendingDeletion""#); assert_eq!(wire(&KeyStatus::Deleted), r#""Deleted""#); assert_eq!(wire(&KeyUsage::EncryptDecrypt), r#""EncryptDecrypt""#); assert_eq!(wire(&KeyUsage::SignVerify), r#""SignVerify""#); assert_eq!(wire(&KeySpec::Aes256), r#""Aes256""#); assert_eq!(wire(&KeySpec::Aes128), r#""Aes128""#); assert_eq!(wire(&KeySpec::ChaCha20), r#""ChaCha20""#); // Algorithm names double as S3 header values, so they are externally // visible as well as persisted. assert_eq!(wire(&EncryptionAlgorithm::Aes256), r#""AES256""#); assert_eq!(wire(&EncryptionAlgorithm::ChaCha20Poly1305), r#""ChaCha20Poly1305""#); assert_eq!(wire(&EncryptionAlgorithm::AwsKms), r#""aws:kms""#); // The string form used on the wire must match the serde form exactly. for algorithm in [ EncryptionAlgorithm::Aes256, EncryptionAlgorithm::ChaCha20Poly1305, EncryptionAlgorithm::AwsKms, ] { assert_eq!( wire(&algorithm), format!("\"{}\"", algorithm.as_str()), "as_str and the serde spelling must not drift apart" ); assert_eq!( algorithm.as_str().parse::().expect("must parse back"), algorithm, "as_str must round-trip through FromStr" ); } // Service status is returned by the admin status endpoint. assert_eq!(wire(&KmsServiceStatus::NotConfigured), r#""NotConfigured""#); assert_eq!(wire(&KmsServiceStatus::Configured), r#""Configured""#); assert_eq!(wire(&KmsServiceStatus::Running), r#""Running""#); assert_eq!(wire(&KmsServiceStatus::Error("boom".to_string())), r#"{"Error":"boom"}"#); // Backup bundle vocabulary: written into manifests that a future version // must still decode. assert_eq!(wire(&ArtifactKind::KeyMaterial), r#""key-material""#); assert_eq!(wire(&ArtifactKind::KeyMetadata), r#""key-metadata""#); assert_eq!(wire(&ArtifactKind::MasterKeySalt), r#""master-key-salt""#); assert_eq!(wire(&ArtifactKind::KmsConfig), r#""kms-config""#); assert_eq!(wire(&ArtifactKind::Alias), r#""alias""#); assert_eq!(wire(&ArtifactKind::Policy), r#""policy""#); assert_eq!(wire(&CompletenessState::InProgress), r#""in-progress""#); assert_eq!(wire(&CompletenessState::Complete), r#""complete""#); assert_eq!(wire(&AeadAlgorithm::Aes256Gcm), r#""aes-256-gcm""#); assert_eq!(wire(&DigestAlgorithm::Sha256), r#""sha-256""#); } /// The header names an encrypted object carries for the rest of its life. #[tokio::test] async fn object_encryption_header_names_are_stable() { let kms = TestKms::local_with(|config| config.default_key_id = Some("serde-key".to_string())).await; kms.create_key("serde-key").await; let service = kms.service().await; let data = b"serde contract".to_vec(); let sse_s3 = service .encrypt_object("bucket", "object", data.as_slice(), &EncryptionAlgorithm::Aes256, None, None) .await .expect("SSE-S3 encrypt"); let sse_kms = service .encrypt_object( "bucket", "object", data.as_slice(), &EncryptionAlgorithm::ChaCha20Poly1305, Some("serde-key"), None, ) .await .expect("SSE-KMS encrypt"); let sse_c = service .encrypt_object_with_customer_key("bucket", "object", data.as_slice(), &[0x5cu8; 32], None) .await .expect("SSE-C encrypt"); let names = |result: &rustfs_kms::EncryptionMetadata| { let mut names: Vec = service.metadata_to_headers(result).into_keys().collect(); names.sort(); names }; insta::assert_yaml_snapshot!("sse_s3_header_names", names(&sse_s3.metadata)); insta::assert_yaml_snapshot!("sse_kms_header_names", names(&sse_kms.metadata)); insta::assert_yaml_snapshot!("sse_c_header_names", names(&sse_c.metadata)); // The encryption context is embedded as a JSON object under one header; // its key set is part of the same contract. let mut context_keys: Vec = sse_s3.metadata.encryption_context.keys().cloned().collect(); context_keys.sort(); insta::assert_yaml_snapshot!("sse_s3_encryption_context_keys", context_keys); } /// The shape of a persisted `KmsConfig` document, per backend. #[test] fn persisted_configuration_shape_is_stable() { let local = KmsConfig::local("/var/lib/rustfs/kms".into()); insta::assert_yaml_snapshot!("kms_config_field_names", field_names(&local)); let local_backend = serde_json::to_value(&local.backend_config).expect("serialize"); insta::assert_yaml_snapshot!("backend_config_local_shape", shape_of(&local_backend)); let static_config = KmsConfig::static_kms(STATIC_KEY_ID.to_string(), static_secret_key()); let static_backend = serde_json::to_value(&static_config.backend_config).expect("serialize"); insta::assert_yaml_snapshot!("backend_config_static_shape", shape_of(&static_backend)); assert!( !serde_json::to_string(&static_config.backend_config) .expect("serialize") .contains(&static_secret_key()), "the static secret is `skip_serializing` and must never appear in a persisted document" ); let vault = KmsConfig::vault(url::Url::parse("https://vault.example.com:8200").expect("url"), "token".to_string()); let vault_backend = serde_json::to_value(&vault.backend_config).expect("serialize"); insta::assert_yaml_snapshot!("backend_config_vault_kv2_shape", shape_of(&vault_backend)); let transit = KmsConfig::vault_transit(url::Url::parse("https://vault.example.com:8200").expect("url"), "token".to_string()); let transit_backend = serde_json::to_value(&transit.backend_config).expect("serialize"); insta::assert_yaml_snapshot!("backend_config_vault_transit_shape", shape_of(&transit_backend)); } /// Every persisted `KmsConfig` must survive a round trip unchanged, so a /// document written by this build is readable by it. #[test] fn persisted_configuration_round_trips() { for (label, config) in [ ("local", KmsConfig::local("/var/lib/rustfs/kms".into())), ( "vault-kv2", KmsConfig::vault(url::Url::parse("https://vault.example.com:8200").expect("url"), "token".to_string()), ), ( "vault-transit", KmsConfig::vault_transit(url::Url::parse("https://vault.example.com:8200").expect("url"), "token".to_string()), ), ] { let encoded = serde_json::to_string(&config).unwrap_or_else(|error| panic!("{label} should serialize: {error}")); let decoded: KmsConfig = serde_json::from_str(&encoded).unwrap_or_else(|error| panic!("{label} should deserialize: {error}")); assert_eq!(decoded.backend, config.backend, "{label}: backend must survive"); assert_eq!(decoded.timeout, config.timeout, "{label}: timeout must survive"); assert_eq!(decoded.retry_attempts, config.retry_attempts, "{label}: retries must survive"); assert_eq!(decoded.enable_cache, config.enable_cache, "{label}: cache flag must survive"); assert_eq!(decoded.default_key_id, config.default_key_id, "{label}: default key must survive"); assert_eq!( serde_json::to_string(&decoded).expect("re-serialize"), encoded, "{label}: re-encoding a decoded document must be byte-identical" ); } // The Static backend is the documented exception: its secret is dropped on // serialization, so a round trip cannot restore it. Recording that here // keeps a future reader from treating it as a bug. let static_config = KmsConfig::static_kms(STATIC_KEY_ID.to_string(), static_secret_key()); let decoded: KmsConfig = serde_json::from_str(&serde_json::to_string(&static_config).expect("serialize")).expect("deserialize"); assert!( decoded.static_config().expect("static config").secret_key.is_empty(), "a persisted static config never carries its secret; the operator re-supplies it" ); assert_eq!( decoded.static_config().expect("static config").key_id, STATIC_KEY_ID, "the key id does survive persistence" ); } /// The manifest a future restore has to decode. #[tokio::test] async fn backup_manifest_shape_is_stable() { use rustfs_kms::LocalConfig; use rustfs_kms::backends::local::LocalKmsClient; use rustfs_kms::backup::{BackupKek, LocalBackupExportRequest, export_local_backup}; let kms = TestKms::local().await; kms.create_key("manifest-shape").await; let client = LocalKmsClient::new(LocalConfig { key_dir: kms.key_dir().expect("key dir"), master_key: None, file_permissions: Some(0o600), }) .await .expect("client"); let out = tempfile::TempDir::new().expect("temp dir"); let manifest = export_local_backup( &client, &BackupKek::new("kek", 1, [0x11u8; 32]).expect("kek"), &LocalBackupExportRequest { backup_id: "shape".to_string(), deployment_identity: "shape-deployment".to_string(), rustfs_version: "0.0.0".to_string(), snapshot_generation: 1, destination: out.path().join("bundle"), sanitized_config: None, }, ) .await .expect("export"); insta::assert_yaml_snapshot!("backup_manifest_field_names", field_names(&manifest)); let artifact = manifest.artifacts.first().expect("at least one artifact"); insta::assert_yaml_snapshot!("backup_artifact_field_names", field_names(artifact)); insta::assert_yaml_snapshot!("backup_kek_descriptor_field_names", field_names(&manifest.backup_kek)); insta::assert_yaml_snapshot!( "backup_local_kdf_field_names", field_names(manifest.local_kdf.as_ref().expect("local kdf")) ); } /// Describe a JSON value as a structural fingerprint: object keys are kept, /// leaf values are replaced by their type name. Stable across runs while still /// catching a renamed or retyped field. fn shape_of(value: &serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Object(map) => { // Sort explicitly rather than relying on `serde_json::Map` being a // `BTreeMap`: that depends on the `preserve_order` feature, and an // unordered fingerprint would make these snapshots flaky. let mut keys: Vec<&String> = map.keys().collect(); keys.sort(); let mut out = serde_json::Map::new(); for key in keys { out.insert(key.clone(), shape_of(&map[key])); } serde_json::Value::Object(out) } serde_json::Value::Array(items) => serde_json::Value::Array(items.iter().map(shape_of).take(1).collect()), serde_json::Value::String(_) => serde_json::Value::String("".to_string()), serde_json::Value::Number(_) => serde_json::Value::String("".to_string()), serde_json::Value::Bool(_) => serde_json::Value::String("".to_string()), serde_json::Value::Null => serde_json::Value::String("".to_string()), } }