diff --git a/crates/policy/src/policy/policy.rs b/crates/policy/src/policy/policy.rs index d412fa789..946f930a8 100644 --- a/crates/policy/src/policy/policy.rs +++ b/crates/policy/src/policy/policy.rs @@ -195,7 +195,8 @@ pub struct BucketPolicyArgs<'a> { #[derive(Serialize, Deserialize, Clone, Default, Debug)] #[serde(deny_unknown_fields)] pub struct BucketPolicy { - #[serde(default, rename = "Id", skip_serializing_if = "ID::is_empty")] + // RUSTFS_COMPAT_TODO(rustfs-6339): accept bucket policies persisted with the legacy "ID" key. Remove after migration tooling rewrites every retained legacy bucket policy. + #[serde(default, rename = "Id", alias = "ID", skip_serializing_if = "ID::is_empty")] pub id: ID, #[serde(rename = "Version")] pub version: String, @@ -2786,7 +2787,7 @@ mod test { let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse"); // Verify empty fields are omitted - assert!(!parsed.as_object().unwrap().contains_key("ID"), "Empty ID should be omitted"); + assert!(parsed.get("Id").is_none(), "Empty ID should be omitted"); let statement = &parsed["Statement"][0]; assert!(!statement.as_object().unwrap().contains_key("Sid"), "Empty Sid should be omitted"); @@ -2809,6 +2810,43 @@ mod test { assert_eq!(statement["Principal"]["AWS"], "*"); } + #[test] + fn test_bucket_policy_deserializes_legacy_id() { + let legacy_policy = br#"{"ID":"","Version":"2012-10-17","Statement":[{"Sid":"","Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"NotAction":[],"Resource":["arn:aws:s3:::bucket/*"],"NotResource":[],"Condition":{}}]}"#; + + let policy: BucketPolicy = + serde_json::from_slice(legacy_policy).expect("bucket policy with legacy ID should deserialize"); + assert!(policy.id.is_empty()); + policy.is_valid().expect("legacy bucket policy should remain valid"); + + let policy: BucketPolicy = serde_json::from_str(r#"{"ID":"legacy-policy","Version":"2012-10-17","Statement":[]}"#) + .expect("non-empty legacy ID should deserialize"); + assert_eq!(policy.id.0, "legacy-policy"); + + let serialized = serde_json::to_value(&policy).expect("bucket policy should serialize"); + assert_eq!(serialized["Id"], "legacy-policy"); + assert!(serialized.get("ID").is_none(), "legacy ID spelling should not be serialized"); + } + + #[test] + fn test_bucket_policy_legacy_id_alias_remains_strict() { + let unknown_field = r#"{"Version":"2012-10-17","Statement":[],"Unexpected":true}"#; + let error = + serde_json::from_str::(unknown_field).expect_err("unrelated unknown fields should remain rejected"); + assert!( + error.to_string().contains("unknown field `Unexpected`"), + "unexpected deserialization error: {error}" + ); + + let duplicate_id = r#"{"Id":"current-policy","ID":"legacy-policy","Version":"2012-10-17","Statement":[]}"#; + let error = serde_json::from_str::(duplicate_id) + .expect_err("canonical and legacy ID fields should not be accepted together"); + assert!( + error.to_string().contains("duplicate field `Id`"), + "unexpected deserialization error: {error}" + ); + } + #[test] fn test_existing_object_tag_condition_helpers() { let identity_policy = Policy::parse_config( diff --git a/docs/architecture/compat-cleanup-register.md b/docs/architecture/compat-cleanup-register.md index 9904b41c9..d7ecec7f5 100644 --- a/docs/architecture/compat-cleanup-register.md +++ b/docs/architecture/compat-cleanup-register.md @@ -12,6 +12,7 @@ for later deletion. ## Open Items +- `rustfs-6339` legacy bucket policy ID casing: earlier RustFS releases persisted the top-level policy identifier as "ID", while current writes use the S3-compatible "Id" spelling. Readers accept both spellings so retained bucket metadata remains usable after upgrade. Remove the legacy alias after migration tooling has rewritten every retained bucket policy using "ID". - `table-publication-fence-v1` table publication fencing: nodes that predate table and table-bucket publication fences can mutate live files while a new node is publishing a catalog pointer. New nodes retain exact object guards until the operator confirms that every serving node uses the new fences. Fleet confirmation also requires non-overlapping active warehouse prefixes and lifecycle workers that exclude table buckets. Remove the exact live-file fallback and the fleet-confirmation gate after the minimum supported RustFS release acquires table fences for registered-table mutations and table-bucket fences for unresolved-prefix mutations. - `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2. - `table-catalog-migration-fence-v1` durable strong migration fence compatibility: version 1 "PREPARING" fences did not distinguish a known-absent global strong snapshot from an unknown baseline, so retries read them but fail closed if the global snapshot is missing. Version 2 preserves the same JSON shape and records the pre-migration global snapshot ETag in the existing target_snapshot_etag field while the fence is "PREPARING". Remove version 1 reads after every supported direct-upgrade source writes version 2 fences and operators have completed or cancelled every older in-progress backing migration.