mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-06 21:33:14 +00:00
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
This commit is contained in:
@@ -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<String, PeerInfo>,
|
||||
#[serde(
|
||||
rename = "updatedAt",
|
||||
@@ -639,31 +643,87 @@ pub struct SRStateInfo {
|
||||
pub api_version: Option<String>,
|
||||
}
|
||||
|
||||
// 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<String, SRBucketInfo>,
|
||||
#[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<String, SRIAMPolicy>,
|
||||
#[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<String, SRPolicyMapping>,
|
||||
#[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<String, UserInfo>,
|
||||
#[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<String, GroupDesc>,
|
||||
#[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<String, SRPolicyMapping>,
|
||||
#[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<String, Value>,
|
||||
#[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<String, ILMExpiryRule>,
|
||||
#[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<String>,
|
||||
@@ -1153,6 +1213,16 @@ where
|
||||
Ok(Option::<Vec<String>>::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<T, D::Error>
|
||||
where
|
||||
T: Deserialize<'de> + Default,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(Option::<T>::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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user