From 04722caa0488917ed6c21050735b423bfe438271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Thu, 6 Aug 2026 04:18:05 +0800 Subject: [PATCH] fix(madmin): accept MinIO PascalCase SRInfo fields and nil-map nulls (#5749) * test(madmin): add MinIO PascalCase SRInfo fixture coverage * fix(madmin): accept MinIO PascalCase SRInfo fields via serde alias * test(madmin): cover MinIO nil-map SRInfo JSON output * fix(madmin): tolerate Go nil-map null in SRInfo deserialization --- crates/madmin/src/site_replication.rs | 279 ++++++++++++++++++++++++-- 1 file changed, 265 insertions(+), 14 deletions(-) diff --git a/crates/madmin/src/site_replication.rs b/crates/madmin/src/site_replication.rs index a8555c904..259b9d7ff 100644 --- a/crates/madmin/src/site_replication.rs +++ b/crates/madmin/src/site_replication.rs @@ -626,7 +626,11 @@ pub struct ILMExpiryRule { pub struct SRStateInfo { #[serde(default, skip_serializing_if = "String::is_empty")] pub name: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub peers: BTreeMap, #[serde( rename = "updatedAt", @@ -639,31 +643,87 @@ pub struct SRStateInfo { pub api_version: Option, } +// madmin-go's SRInfo top-level fields carry no json tags (except APIVersion), +// so MinIO emits them in PascalCase; the aliases below accept that on +// deserialization while serialization stays camelCase. Nested structs +// (SRBucketInfo, SRStateInfo, ...) do have lowercase tags in madmin-go — +// do not spread aliases to them. #[derive(Debug, Serialize, Deserialize, Default)] pub struct SRInfo { - #[serde(default)] + #[serde(alias = "Enabled", default)] pub enabled: bool, - #[serde(default, skip_serializing_if = "String::is_empty")] + #[serde(alias = "Name", default, skip_serializing_if = "String::is_empty")] pub name: String, - #[serde(rename = "deploymentID", default, skip_serializing_if = "String::is_empty")] + #[serde( + rename = "deploymentID", + alias = "DeploymentID", + default, + skip_serializing_if = "String::is_empty" + )] pub deployment_id: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + alias = "Buckets", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub buckets: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + alias = "Policies", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub policies: BTreeMap, - #[serde(rename = "userPolicies", default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + rename = "userPolicies", + alias = "UserPolicies", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub user_policies: BTreeMap, - #[serde(rename = "userInfoMap", default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + rename = "userInfoMap", + alias = "UserInfoMap", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub user_info_map: BTreeMap, - #[serde(rename = "groupDescMap", default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + rename = "groupDescMap", + alias = "GroupDescMap", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub group_desc_map: BTreeMap, - #[serde(rename = "groupPolicies", default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + rename = "groupPolicies", + alias = "GroupPolicies", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub group_policies: BTreeMap, - #[serde(rename = "replicationCfg", default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + rename = "replicationCfg", + alias = "ReplicationCfg", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub replication_cfg: BTreeMap, - #[serde(rename = "ilmExpiryRules", default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + rename = "ilmExpiryRules", + alias = "ILMExpiryRules", + default, + deserialize_with = "deserialize_null_default", + skip_serializing_if = "BTreeMap::is_empty" + )] pub ilm_expiry_rules: BTreeMap, - #[serde(default)] + #[serde(alias = "State", default, deserialize_with = "deserialize_null_default")] pub state: SRStateInfo, #[serde(rename = "apiVersion", skip_serializing_if = "Option::is_none")] pub api_version: Option, @@ -1153,6 +1213,16 @@ where Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) } +// Go json.Marshal emits nil maps (and nil struct pointers) as null; treat +// explicit null like a missing field so MinIO SRInfo payloads parse. +fn deserialize_null_default<'de, T, D>(deserializer: D) -> Result +where + T: Deserialize<'de> + Default, + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SRStateEditReq { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -1320,7 +1390,7 @@ pub struct SiteNetPerfResult { #[cfg(test)] mod tests { - use super::{PeerInfo, PeerSite, SRResyncOpStatus}; + use super::{PeerInfo, PeerSite, SRInfo, SRResyncOpStatus}; use serde_json::{Value, json}; const TEST_CA_CERT: &str = "-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----"; @@ -1510,4 +1580,185 @@ mod tests { status_json ); } + + /// Mirrors `json.Marshal(madmin.SRInfo{...})` output from MinIO: the + /// madmin-go SRInfo top-level fields carry no json tags (except + /// APIVersion), so Go emits them in PascalCase, while every nested + /// struct has lowercase tags. + fn minio_pascal_case_sr_info_json() -> Value { + json!({ + "Enabled": true, + "Name": "site-minio", + "DeploymentID": "minio-deploy-1", + "Buckets": { + "photos": { + "bucket": "photos", + "versioningConfig": "PHZlcnNpb25pbmcvPg==" + } + }, + "Policies": { + "readonly": { + "policy": { "Version": "2012-10-17" }, + "updatedAt": "2026-07-22T01:00:00Z" + } + }, + "UserPolicies": { + "alice": { + "userOrGroup": "alice", + "userType": 1, + "isGroup": false, + "policy": "readonly" + } + }, + "UserInfoMap": { + "alice": { + "status": "enabled", + "updatedAt": "2026-07-22T01:00:00Z" + } + }, + "GroupDescMap": { + "devs": { + "name": "devs", + "status": "enabled", + "members": ["alice"], + "policy": "readonly", + "updatedAt": "2026-07-22T01:00:00Z" + } + }, + "GroupPolicies": { + "devs": { + "userOrGroup": "devs", + "userType": 0, + "isGroup": true, + "policy": "readonly" + } + }, + "ReplicationCfg": { + "photos": { "role": "arn:minio:replication::minio-deploy-1:photos" } + }, + "ILMExpiryRules": { + "rule-1": { + "ilm-rule": "PFJ1bGUvPg==", + "bucket": "photos" + } + }, + "State": { + "name": "site-minio", + "peers": { + "minio-deploy-1": { + "endpoint": "https://minio.example.com", + "name": "site-minio", + "deploymentID": "minio-deploy-1" + } + }, + "updatedAt": "2026-07-22T01:00:00Z" + }, + "apiVersion": "1" + }) + } + + #[test] + fn sr_info_deserializes_minio_pascal_case_top_level_fields() { + let info: SRInfo = + serde_json::from_value(minio_pascal_case_sr_info_json()).expect("MinIO PascalCase SRInfo JSON should deserialize"); + + assert!(info.enabled, "Enabled must map to enabled"); + assert_eq!(info.name, "site-minio"); + assert_eq!(info.deployment_id, "minio-deploy-1", "DeploymentID must map to deployment_id"); + assert!(info.buckets.contains_key("photos"), "Buckets must map to buckets"); + assert!(info.policies.contains_key("readonly"), "Policies must map to policies"); + assert!(info.user_policies.contains_key("alice"), "UserPolicies must map to user_policies"); + assert!(info.user_info_map.contains_key("alice"), "UserInfoMap must map to user_info_map"); + assert!(info.group_desc_map.contains_key("devs"), "GroupDescMap must map to group_desc_map"); + assert!(info.group_policies.contains_key("devs"), "GroupPolicies must map to group_policies"); + assert!(info.replication_cfg.contains_key("photos"), "ReplicationCfg must map to replication_cfg"); + assert!( + info.ilm_expiry_rules.contains_key("rule-1"), + "ILMExpiryRules must map to ilm_expiry_rules" + ); + assert!( + info.state.peers.contains_key("minio-deploy-1"), + "State must map to state with populated peers" + ); + } + + #[test] + fn sr_info_deserializes_minio_nil_maps_as_empty() { + // Go json.Marshal emits nil maps as null; an SR-unconfigured MinIO + // site reports SRInfo with every map nil. + let nil_map_json = json!({ + "Enabled": false, + "Name": "site-minio", + "DeploymentID": "minio-deploy-1", + "Buckets": null, + "Policies": null, + "UserPolicies": null, + "UserInfoMap": null, + "GroupDescMap": null, + "GroupPolicies": null, + "ReplicationCfg": null, + "ILMExpiryRules": null, + "State": null, + "apiVersion": "1" + }); + + let info: SRInfo = serde_json::from_value(nil_map_json).expect("MinIO nil-map SRInfo JSON should deserialize"); + + assert!(!info.enabled); + assert_eq!(info.deployment_id, "minio-deploy-1"); + assert!(info.buckets.is_empty()); + assert!(info.policies.is_empty()); + assert!(info.user_policies.is_empty()); + assert!(info.user_info_map.is_empty()); + assert!(info.group_desc_map.is_empty()); + assert!(info.group_policies.is_empty()); + assert!(info.replication_cfg.is_empty()); + assert!(info.ilm_expiry_rules.is_empty()); + assert!(info.state.peers.is_empty()); + + // Go's zero-value SRStateInfo serializes as an object whose nil + // peers map is null, not as a null State. + let nil_peers_json = json!({ + "Enabled": false, + "DeploymentID": "minio-deploy-1", + "State": { "name": "", "peers": null } + }); + + let info: SRInfo = serde_json::from_value(nil_peers_json).expect("MinIO nil-peers SRInfo JSON should deserialize"); + + assert_eq!(info.deployment_id, "minio-deploy-1"); + assert!(info.state.peers.is_empty()); + } + + #[test] + fn sr_info_serialization_stays_camel_case() { + let info: SRInfo = + serde_json::from_value(minio_pascal_case_sr_info_json()).expect("MinIO PascalCase SRInfo JSON should deserialize"); + + let value = serde_json::to_value(info).expect("SRInfo should serialize"); + let object = value.as_object().expect("SRInfo JSON should be an object"); + + for camel_key in [ + "enabled", + "name", + "deploymentID", + "buckets", + "policies", + "userPolicies", + "userInfoMap", + "groupDescMap", + "groupPolicies", + "replicationCfg", + "ilmExpiryRules", + "state", + ] { + assert!(object.contains_key(camel_key), "serialized SRInfo must keep camelCase key {camel_key}"); + } + for pascal_key in ["Enabled", "Name", "DeploymentID", "Buckets", "State", "ILMExpiryRules"] { + assert!( + !object.contains_key(pascal_key), + "serialized SRInfo must not emit PascalCase key {pascal_key}" + ); + } + } }