fix(admin): redact and seal config history (#5206)

This commit is contained in:
cxymds
2026-07-24 23:54:24 +08:00
committed by GitHub
parent 938f7296f9
commit 2b6cc0ee08
+488 -36
View File
@@ -16,7 +16,8 @@ use crate::admin::auth::validate_admin_request;
use crate::admin::handlers::supervise_admin_mutation;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::{
current_app_context, current_object_store_handle_for_context, current_server_config_for_context, publish_server_config,
current_action_credentials, current_app_context, current_object_store_handle_for_context, current_server_config_for_context,
publish_server_config,
};
use crate::admin::service::config::{
CONFIG_WORKER_RELOAD_FAILURE_STATE, EVENT_CONFIG_WORKER_RELOAD_FAILED, FULL_CONFIG_WORKER_SUBSYSTEMS, LOG_COMPONENT_ADMIN,
@@ -86,6 +87,7 @@ use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::env;
use std::future::Future;
use std::mem::size_of;
use time::OffsetDateTime;
use tracing::warn;
use uuid::Uuid;
@@ -97,6 +99,8 @@ const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8";
const CONFIG_HISTORY_PREFIX: &str = "config/history";
const CONFIG_HISTORY_SUFFIX: &str = ".kv";
const CONFIG_HISTORY_SNAPSHOT_HEADER: &str = "# rustfs-config-history: full-snapshot-v1";
const CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER: &str = "# rustfs-config-history: snapshot-envelope-v2";
const CONFIG_HISTORY_ENCRYPTED_HEADER: &str = "# rustfs-config-history: encrypted-full-snapshot-v2";
const CONFIG_APPLIED_HEADER: &str = "x-rustfs-config-applied";
const CONFIG_APPLIED_COMPAT_HEADER: &str = "x-minio-config-applied";
const CONFIG_APPLIED_TRUE: &str = "true";
@@ -166,6 +170,16 @@ struct ConfigHistoryEntry {
create_time: OffsetDateTime,
#[serde(rename = "Data", skip_serializing_if = "Option::is_none")]
data: Option<String>,
#[serde(rename = "DataRedacted", skip_serializing_if = "Option::is_none")]
data_redacted: Option<bool>,
}
impl ConfigHistoryEntry {
fn set_redacted_data(&mut self, persisted: &[u8]) -> S3Result<()> {
self.data = Some(redact_config_history_data(persisted)?);
self.data_redacted = Some(true);
Ok(())
}
}
const STORAGE_CLASS_HELP_KEYS: &[HelpKeyMetadata] = &[
@@ -839,8 +853,8 @@ async fn save_server_config_history(data: &[u8]) -> S3Result<String> {
/// this record while holding the server-config write lock, before persisting
/// their replacement state. The version marker deliberately makes older
/// directive-only entries distinguishable so restore can reject them safely.
fn encode_config_history_snapshot(config: &ServerConfig) -> Vec<u8> {
let rendered = render_full_config(config);
fn encode_config_history_recovery(config: &ServerConfig) -> Vec<u8> {
let rendered = render_full_config(config, false);
let mut snapshot = Vec::with_capacity(CONFIG_HISTORY_SNAPSHOT_HEADER.len() + 1 + rendered.len());
snapshot.extend_from_slice(CONFIG_HISTORY_SNAPSHOT_HEADER.as_bytes());
snapshot.push(b'\n');
@@ -848,8 +862,106 @@ fn encode_config_history_snapshot(config: &ServerConfig) -> Vec<u8> {
snapshot
}
fn encode_config_history_snapshot(config: &ServerConfig) -> S3Result<Vec<u8>> {
let recovery = encode_config_history_recovery(config);
let display = render_full_config(config, true);
let recovery_len =
u64::try_from(recovery.len()).map_err(|_| s3_error!(InternalError, "config history recovery snapshot is too large"))?;
let mut snapshot =
Vec::with_capacity(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.len() + 1 + size_of::<u64>() + recovery.len() + display.len());
snapshot.extend_from_slice(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.as_bytes());
snapshot.push(b'\n');
snapshot.extend_from_slice(&recovery_len.to_be_bytes());
snapshot.extend_from_slice(&recovery);
snapshot.extend_from_slice(&display);
Ok(snapshot)
}
fn split_config_history_snapshot(data: &[u8]) -> S3Result<Option<(&[u8], &[u8])>> {
let Some(envelope) = data.strip_prefix(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.as_bytes()) else {
return Ok(None);
};
let envelope = envelope
.strip_prefix(b"\n")
.ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope header is malformed"))?;
let (recovery_len, payload) = envelope
.split_at_checked(size_of::<u64>())
.ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope is truncated"))?;
let recovery_len = u64::from_be_bytes(
recovery_len
.try_into()
.map_err(|_| s3_error!(InvalidRequest, "config history snapshot envelope length is malformed"))?,
);
let recovery_len = usize::try_from(recovery_len)
.map_err(|_| s3_error!(InvalidRequest, "config history recovery snapshot length is unsupported"))?;
let (recovery, display) = payload
.split_at_checked(recovery_len)
.ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope recovery data is truncated"))?;
Ok(Some((recovery, display)))
}
fn config_history_encryption_key() -> S3Result<Vec<u8>> {
if let Some(key) = rustfs_iam::keyring::encrypt_key() {
return Ok(key);
}
current_action_credentials()
.map(|credentials| credentials.secret_key.into_bytes())
.filter(|key| !key.is_empty())
.ok_or_else(|| s3_error!(InternalError, "config history encryption key is not initialized"))
}
fn config_history_decryption_keys() -> S3Result<Vec<Vec<u8>>> {
let mut keys = rustfs_iam::keyring::decrypt_keys();
if let Some(key) = current_action_credentials()
.map(|credentials| credentials.secret_key.into_bytes())
.filter(|key| !key.is_empty())
{
keys.push(key);
}
if keys.is_empty() {
return Err(s3_error!(InternalError, "config history decryption key is not initialized"));
}
Ok(keys)
}
fn seal_config_history_snapshot(snapshot: &[u8], key: &[u8]) -> S3Result<Vec<u8>> {
let ciphertext = rustfs_crypto::encrypt_stream_io(key, snapshot)
.map_err(|err| s3_error!(InternalError, "failed to encrypt config history snapshot: {}", err))?;
let mut sealed = Vec::with_capacity(CONFIG_HISTORY_ENCRYPTED_HEADER.len() + 1 + ciphertext.len());
sealed.extend_from_slice(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes());
sealed.push(b'\n');
sealed.extend_from_slice(&ciphertext);
Ok(sealed)
}
fn open_config_history_data_with_keys(data: &[u8], keys: &[Vec<u8>]) -> S3Result<Vec<u8>> {
let Some(ciphertext) = data.strip_prefix(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()) else {
return Ok(data.to_vec());
};
let ciphertext = ciphertext
.strip_prefix(b"\n")
.ok_or_else(|| s3_error!(InvalidRequest, "encrypted config history header is malformed"))?;
for key in keys {
if let Ok(plaintext) = rustfs_crypto::decrypt_stream_io(key, ciphertext) {
return Ok(plaintext);
}
}
Err(s3_error!(InvalidRequest, "config history snapshot cannot be decrypted"))
}
fn open_config_history_data(data: &[u8]) -> S3Result<Vec<u8>> {
if data.starts_with(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()) {
return open_config_history_data_with_keys(data, &config_history_decryption_keys()?);
}
Ok(data.to_vec())
}
fn decode_config_history_snapshot(data: &[u8]) -> S3Result<ServerConfig> {
let text = std::str::from_utf8(data).map_err(|_| s3_error!(InvalidRequest, "config history entry is not valid UTF-8"))?;
let data = open_config_history_data(data)?;
let recovery = split_config_history_snapshot(&data)?.map_or(data.as_slice(), |(recovery, _)| recovery);
let text = std::str::from_utf8(recovery).map_err(|_| s3_error!(InvalidRequest, "config history entry is not valid UTF-8"))?;
let Some(snapshot) = text.strip_prefix(CONFIG_HISTORY_SNAPSHOT_HEADER) else {
return Err(s3_error!(
InvalidRequest,
@@ -879,7 +991,9 @@ fn validate_restore_rollback_generation(current: &ServerConfig, restored: &Serve
}
async fn save_server_config_history_snapshot(config: &ServerConfig) -> S3Result<String> {
save_server_config_history(&encode_config_history_snapshot(config)).await
let snapshot = encode_config_history_snapshot(config)?;
let sealed = seal_config_history_snapshot(&snapshot, &config_history_encryption_key()?)?;
save_server_config_history(&sealed).await
}
async fn cleanup_failed_config_history_snapshot(restore_id: &str) {
@@ -939,25 +1053,11 @@ async fn list_server_config_history(with_data: bool, count: Option<usize>) -> S3
continue;
};
let data = if with_data {
Some(
String::from_utf8(
read_admin_config(store.clone(), &object.name)
.await
.map_err(ApiError::from)
.map_err(S3Error::from)?,
)
.map_err(ApiError::other)
.map_err(S3Error::from)?,
)
} else {
None
};
entries.push(ConfigHistoryEntry {
restore_id,
create_time: object.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH),
data,
data: None,
data_redacted: None,
});
}
@@ -971,7 +1071,17 @@ async fn list_server_config_history(with_data: bool, count: Option<usize>) -> S3
}
}
Ok(trim_history_entries(entries, count))
let mut entries = trim_history_entries(entries, count);
if with_data {
for entry in &mut entries {
let persisted = read_admin_config(store.clone(), &history_object_name(&entry.restore_id))
.await
.map_err(ApiError::from)
.map_err(S3Error::from)?;
entry.set_redacted_data(&persisted)?;
}
}
Ok(entries)
}
fn normalize_target(target: &str) -> String {
@@ -1159,6 +1269,99 @@ fn is_sensitive_key_name(key: &str) -> bool {
|| normalized.ends_with("_private_key")
}
fn is_sensitive_env_key_name(key: &str) -> bool {
if is_sensitive_key_name(key) {
return true;
}
let normalized = format!("_{}_", key.trim().to_ascii_lowercase());
normalized.contains("_token_")
|| normalized.contains("_client_key_")
|| normalized.contains("_tls_client_key_")
|| normalized.contains("_private_key_")
}
fn is_sensitive_config_key(sub_system: &str, key: &str) -> bool {
if is_sensitive_key_name(key) {
return true;
}
if DEFAULT_KVS.get().is_none() {
crate::admin::storage_api::config::init_admin_config_defaults();
}
DEFAULT_KVS
.get()
.and_then(|defaults| defaults.get(sub_system))
.and_then(|kvs| kvs.0.iter().find(|entry| entry.key == key))
.is_some_and(|entry| entry.hidden_if_empty)
}
fn redact_config_history_line(line: &str) -> String {
let trimmed = line.trim();
if trimmed.is_empty() {
return String::new();
}
let (comment_prefix, content) = match trimmed.strip_prefix('#') {
Some(content) => ("# ", content.trim()),
None => ("", trimmed),
};
let Ok(tokens) = tokenize_config_line(content) else {
return format!("{comment_prefix}{REDACTED_VALUE}");
};
let Some(scope) = tokens.first() else {
return String::new();
};
if scope.contains('=') {
let assignments = tokens
.iter()
.map(|token| {
let Some((key, value)) = token.split_once('=') else {
return REDACTED_VALUE.to_string();
};
let value = if is_sensitive_env_key_name(key) {
REDACTED_VALUE
} else {
value
};
format_kv_pair(key, value)
})
.collect::<Vec<_>>();
return format!("{comment_prefix}{}", assignments.join(" "));
}
let sub_system = scope.split_once(':').map_or(scope.as_str(), |(sub_system, _)| sub_system);
let mut redacted = Vec::with_capacity(tokens.len());
redacted.push(scope.clone());
for token in tokens.iter().skip(1) {
let Some((key, value)) = token.split_once('=') else {
redacted.push(token.clone());
continue;
};
let value = if is_sensitive_config_key(sub_system, key) {
REDACTED_VALUE
} else {
value
};
redacted.push(format_kv_pair(key, value));
}
format!("{comment_prefix}{}", redacted.join(" "))
}
fn redact_config_history_data(data: &[u8]) -> S3Result<String> {
let plaintext = open_config_history_data(data)?;
if let Some((_, display)) = split_config_history_snapshot(&plaintext)? {
return Ok(std::str::from_utf8(display).unwrap_or(REDACTED_VALUE).to_string());
}
let Ok(text) = std::str::from_utf8(&plaintext) else {
return Ok(REDACTED_VALUE.to_string());
};
let text = text
.strip_prefix(CONFIG_HISTORY_SNAPSHOT_HEADER)
.and_then(|snapshot| snapshot.strip_prefix('\n'))
.unwrap_or(text);
Ok(text.lines().map(redact_config_history_line).collect::<Vec<_>>().join("\n"))
}
fn apply_set_directives(config: &mut ServerConfig, directives: &[ConfigDirective]) -> S3Result<()> {
for directive in directives {
let targets = config.0.entry(directive.sub_system.clone()).or_default();
@@ -1383,7 +1586,7 @@ fn render_selected_config(config: &ServerConfig, selector: &ConfigSelector, reda
Ok(lines.join("\n").into_bytes())
}
fn render_full_config(config: &ServerConfig) -> Vec<u8> {
fn render_full_config(config: &ServerConfig, redact_secrets: bool) -> Vec<u8> {
let mut subsystems = config.0.iter().collect::<Vec<_>>();
subsystems.sort_by_key(|(lhs, _)| *lhs);
@@ -1393,7 +1596,7 @@ fn render_full_config(config: &ServerConfig) -> Vec<u8> {
sorted_targets.sort_by_key(|(lhs, _)| *lhs);
for (target, kvs) in sorted_targets {
if let Some(line) = render_scope_line(sub_system, target, kvs, false) {
if let Some(line) = render_scope_line(sub_system, target, kvs, redact_secrets) {
lines.push(line);
}
}
@@ -2071,7 +2274,7 @@ impl Operation for GetConfigHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
let cred = validate_config_admin_request(&req).await?;
let config = load_active_server_config().await?;
let payload = render_full_config(&config);
let payload = render_full_config(&config, false);
let (body, content_type) = encode_config_payload(req.uri.path(), &cred.secret_key, payload, TEXT_CONTENT_TYPE)?;
response_with_content_type(StatusCode::OK, body, &content_type)
}
@@ -2128,7 +2331,9 @@ impl Operation for SetConfigHandler {
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::{Arc, Mutex};
use temp_env::with_vars;
#[tokio::test]
async fn prepared_config_commit_persists_before_publish() {
@@ -2309,12 +2514,12 @@ identity_openid config_url="https://issuer.example" client_id="console""#,
)
.expect("apply original directives");
let exported = String::from_utf8(render_full_config(&original)).expect("utf8 export");
let exported = String::from_utf8(render_full_config(&original, false)).expect("utf8 export");
let mut restored = ServerConfig::new();
apply_set_directives(&mut restored, &parse_config_directives(&exported, false).expect("parse exported config"))
.expect("apply restored directives");
assert_eq!(render_full_config(&original), render_full_config(&restored));
assert_eq!(render_full_config(&original, false), render_full_config(&restored, false));
}
#[test]
@@ -2623,25 +2828,269 @@ notify_webhook:primary endpoint="https://hooks.example" auth_token="target-secre
)
.expect("apply fixture");
let encoded = encode_config_history_snapshot(&original);
let encoded = encode_config_history_snapshot(&original).expect("encode snapshot");
let restored = decode_config_history_snapshot(&encoded).expect("decode snapshot");
assert_eq!(render_full_config(&restored), render_full_config(&original));
let rendered = String::from_utf8(render_full_config(&restored)).expect("utf8");
assert_eq!(render_full_config(&restored, false), render_full_config(&original, false));
let rendered = String::from_utf8(render_full_config(&restored, false)).expect("utf8");
assert!(rendered.contains(r#"client_secret="unrelated-secret""#));
assert!(rendered.contains(r#"notify_webhook:primary"#));
assert!(rendered.contains(r#"auth_token="target-secret""#));
}
#[test]
fn sealed_history_snapshot_hides_secrets_and_rejects_wrong_key() {
crate::admin::storage_api::config::init_admin_config_defaults();
let mut config = ServerConfig::new();
apply_set_directives(
&mut config,
&parse_config_directives(r#"identity_openid client_id="client" client_secret="snapshot-canary-secret""#, false)
.expect("parse fixture"),
)
.expect("apply fixture");
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
let sealed = seal_config_history_snapshot(&snapshot, b"history-test-key").expect("seal snapshot");
assert!(sealed.starts_with(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()));
assert!(
!sealed
.windows(b"snapshot-canary-secret".len())
.any(|window| window == b"snapshot-canary-secret")
);
let wrong_key_error =
open_config_history_data_with_keys(&sealed, &[b"wrong-key".to_vec()]).expect_err("wrong key must fail closed");
assert!(!wrong_key_error.to_string().contains("snapshot-canary-secret"));
let opened = open_config_history_data_with_keys(&sealed, &[b"history-test-key".to_vec()]).expect("open snapshot");
assert_eq!(opened, snapshot);
}
#[test]
#[serial]
fn history_snapshot_key_rotation_uses_current_and_old_iam_master_keys() {
crate::admin::storage_api::config::init_admin_config_defaults();
let mut config = ServerConfig::new();
apply_set_directives(
&mut config,
&parse_config_directives(r#"storage_class standard="EC:2""#, false).expect("parse fixture"),
)
.expect("apply fixture");
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
let sealed = with_vars(
[
(rustfs_iam::keyring::ENV_IAM_MASTER_KEY, Some("history-master-old")),
(rustfs_iam::keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>),
],
|| {
let key = config_history_encryption_key().expect("current history encryption key");
seal_config_history_snapshot(&snapshot, &key).expect("seal with current key")
},
);
with_vars(
[
(rustfs_iam::keyring::ENV_IAM_MASTER_KEY, Some("history-master-new")),
(
rustfs_iam::keyring::ENV_IAM_MASTER_KEY_OLD_KEYS,
Some("history-master-unused,history-master-old"),
),
],
|| {
let restored = decode_config_history_snapshot(&sealed).expect("restore with old-key fallback");
assert_eq!(render_full_config(&restored, false), render_full_config(&config, false));
},
);
}
#[test]
fn history_snapshot_listing_redacts_known_and_fallback_secrets() {
crate::admin::storage_api::config::init_admin_config_defaults();
let mut config = ServerConfig::new();
apply_set_directives(
&mut config,
&parse_config_directives(
r#"identity_openid:primary client_id="client" client_secret="snapshot-client-canary"
notify_webhook:primary endpoint="https://hooks.example" auth_token="snapshot-token-canary"
notify_webhook:secondary endpoint="https://secondary.example" client_key="snapshot-key-canary""#,
false,
)
.expect("parse fixture"),
)
.expect("apply fixture");
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
let listed = redact_config_history_data(&snapshot).expect("redact snapshot");
assert!(listed.contains(r#"client_id="client""#));
assert!(listed.contains(r#"endpoint="https://hooks.example""#));
assert!(listed.contains(r#"client_secret="*redacted*""#));
assert!(listed.contains(r#"auth_token="*redacted*""#));
assert!(listed.contains(r#"client_key="*redacted*""#));
assert!(!listed.contains("snapshot-client-canary"));
assert!(!listed.contains("snapshot-token-canary"));
assert!(!listed.contains("snapshot-key-canary"));
}
#[test]
fn history_snapshot_listing_preserves_creation_time_hidden_metadata() {
crate::admin::storage_api::config::init_admin_config_defaults();
let mut config = ServerConfig::new();
let targets = config.0.entry(IDENTITY_OPENID_SUB_SYS.to_string()).or_default();
targets.insert(
DEFAULT_DELIMITER.to_string(),
KVS(vec![KV {
key: OIDC_CLIENT_ID.to_string(),
value: "metadata-hidden-canary".to_string(),
hidden_if_empty: true,
}]),
);
let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot");
let listed = redact_config_history_data(&snapshot).expect("redact snapshot");
let restored = decode_config_history_snapshot(&snapshot).expect("decode snapshot");
assert!(listed.contains(r#"client_id="*redacted*""#));
assert!(!listed.contains("metadata-hidden-canary"));
assert!(
String::from_utf8(render_full_config(&restored, false))
.expect("utf8 restored config")
.contains(r#"client_id="metadata-hidden-canary""#)
);
}
#[test]
fn plaintext_v1_history_snapshot_remains_restorable_and_lists_redacted() {
crate::admin::storage_api::config::init_admin_config_defaults();
let mut config = ServerConfig::new();
apply_set_directives(
&mut config,
&parse_config_directives(r#"identity_openid client_id="legacy-client" client_secret="plaintext-v1-canary""#, false)
.expect("parse fixture"),
)
.expect("apply fixture");
let snapshot = encode_config_history_recovery(&config);
let listed = redact_config_history_data(&snapshot).expect("redact v1 snapshot");
let restored = decode_config_history_snapshot(&snapshot).expect("restore v1 snapshot");
assert!(listed.contains(r#"client_secret="*redacted*""#));
assert!(!listed.contains("plaintext-v1-canary"));
assert!(
String::from_utf8(render_full_config(&restored, false))
.expect("utf8 restored config")
.contains(r#"client_secret="plaintext-v1-canary""#)
);
}
#[test]
fn legacy_history_listing_redacts_quotes_env_lines_and_multiple_targets() {
let history = br#"identity_openid:primary client_id="visible-client" client_secret="quoted canary secret"
notify_webhook:primary endpoint="https://hooks.example" auth_token="target-canary-token"
notify_webhook:secondary endpoint="https://secondary.example" password="fallback-canary-password"
# RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY="env-canary-token"
RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="env-canary-secret""#;
let listed = redact_config_history_data(history).expect("redact legacy history");
assert!(listed.contains(r#"client_id="visible-client""#));
assert!(listed.contains(r#"endpoint="https://hooks.example""#));
assert!(listed.contains(r#"client_secret="*redacted*""#));
assert!(listed.contains(r#"auth_token="*redacted*""#));
assert!(listed.contains(r#"password="*redacted*""#));
assert!(listed.contains(r#"# RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY="*redacted*""#));
assert!(listed.contains(r#"RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="*redacted*""#));
for canary in [
"quoted canary secret",
"target-canary-token",
"fallback-canary-password",
"env-canary-token",
"env-canary-secret",
] {
assert!(!listed.contains(canary));
}
}
#[test]
fn corrupt_legacy_history_listing_fails_closed_without_echoing_bytes() {
let malformed = redact_config_history_data(b"identity_openid client_secret=\"unterminated-canary")
.expect("malformed history is replaced");
let binary =
redact_config_history_data(&[0xff, 0xfe, b's', b'e', b'c', b'r', b'e', b't']).expect("binary history is replaced");
assert_eq!(malformed, REDACTED_VALUE);
assert_eq!(binary, REDACTED_VALUE);
assert!(!malformed.contains("unterminated-canary"));
assert!(!binary.contains("secret"));
}
#[test]
fn malformed_history_snapshot_envelope_fails_closed() {
let mut truncated_length = format!("{CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER}\n").into_bytes();
truncated_length.extend_from_slice(b"canary");
let mut truncated_recovery = format!("{CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER}\n").into_bytes();
truncated_recovery.extend_from_slice(&u64::MAX.to_be_bytes());
truncated_recovery.extend_from_slice(b"envelope-canary");
for malformed in [truncated_length, truncated_recovery] {
let error = redact_config_history_data(&malformed).expect_err("malformed envelope must fail closed");
assert_eq!(error.code(), &S3ErrorCode::InvalidRequest);
assert!(!error.to_string().contains("canary"));
}
}
#[test]
fn history_response_marks_data_as_redacted_without_serializing_canary() {
let mut entry = ConfigHistoryEntry {
restore_id: "restore-id".to_string(),
create_time: OffsetDateTime::UNIX_EPOCH,
data: None,
data_redacted: None,
};
entry
.set_redacted_data(b"identity_openid client_secret=\"history-response-canary\"")
.expect("redact response data");
let payload = serde_json::to_string(&entry).expect("serialize history entry");
assert!(payload.contains(r#""DataRedacted":true"#));
assert!(payload.contains("*redacted*"));
assert!(!payload.contains("history-response-canary"));
}
#[test]
fn minio_compatible_history_response_encrypts_only_redacted_data() {
let mut entry = ConfigHistoryEntry {
restore_id: "restore-id".to_string(),
create_time: OffsetDateTime::UNIX_EPOCH,
data: None,
data_redacted: None,
};
entry
.set_redacted_data(b"identity_openid client_secret=\"compat-history-canary\"")
.expect("redact response data");
let payload = serde_json::to_vec(&[entry]).expect("serialize history response");
let (encoded, content_type) =
encode_config_payload("/minio/admin/v3/list-config-history-kv", "compat-test-key", payload, JSON_CONTENT_TYPE)
.expect("encode compatible response");
let decoded = rustfs_crypto::decrypt_stream_io(b"compat-test-key", &encoded).expect("decrypt compatible response");
let decoded = String::from_utf8(decoded).expect("utf8 response");
assert_eq!(content_type, OCTET_STREAM_CONTENT_TYPE);
assert!(decoded.contains(r#""DataRedacted":true"#));
assert!(decoded.contains("*redacted*"));
assert!(!decoded.contains("compat-history-canary"));
}
#[test]
fn history_snapshot_supports_complete_default_config() {
crate::admin::storage_api::config::init_admin_config_defaults();
let original = ServerConfig::new();
let encoded = encode_config_history_snapshot(&original);
let encoded = encode_config_history_snapshot(&original).expect("encode snapshot");
let restored = decode_config_history_snapshot(&encoded).expect("decode default snapshot");
assert_eq!(render_full_config(&restored), render_full_config(&original));
assert_eq!(render_full_config(&restored, false), render_full_config(&original, false));
}
#[test]
@@ -2658,7 +3107,7 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second
.expect("parse before fixture"),
)
.expect("apply before fixture");
let snapshot = encode_config_history_snapshot(&before);
let snapshot = encode_config_history_snapshot(&before).expect("encode snapshot");
let mut after = before.clone();
apply_set_directives(
@@ -2672,8 +3121,8 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second
);
let restored = decode_config_history_snapshot(&snapshot).expect("restore pre-change snapshot");
assert_eq!(render_full_config(&restored), render_full_config(&before));
assert_ne!(render_full_config(&restored), render_full_config(&after));
assert_eq!(render_full_config(&restored, false), render_full_config(&before, false));
assert_ne!(render_full_config(&restored, false), render_full_config(&after, false));
}
#[test]
@@ -2718,16 +3167,19 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second
restore_id: "oldest".to_string(),
create_time: OffsetDateTime::from_unix_timestamp(1).expect("timestamp"),
data: None,
data_redacted: None,
},
ConfigHistoryEntry {
restore_id: "middle".to_string(),
create_time: OffsetDateTime::from_unix_timestamp(2).expect("timestamp"),
data: None,
data_redacted: None,
},
ConfigHistoryEntry {
restore_id: "newest".to_string(),
create_time: OffsetDateTime::from_unix_timestamp(3).expect("timestamp"),
data: None,
data_redacted: None,
},
];
@@ -2832,7 +3284,7 @@ identity_openid client_id="existing-client""#,
.expect("apply");
// Simulate "mc admin config export" (render_full_config)
let full_output = String::from_utf8(render_full_config(&config)).expect("utf8");
let full_output = String::from_utf8(render_full_config(&config, false)).expect("utf8");
// Simulate "mc admin config get storage_class" (target=None, redact=true)
let selected_output = String::from_utf8(