diff --git a/codegen/src/v1/mod.rs b/codegen/src/v1/mod.rs index 8b239abe..1a89f49c 100644 --- a/codegen/src/v1/mod.rs +++ b/codegen/src/v1/mod.rs @@ -27,7 +27,7 @@ fn write_file(path: &str, f: impl FnOnce()) { } #[derive(Debug, Clone, Copy)] -enum Patch { +pub enum Patch { Minio, } @@ -76,7 +76,7 @@ fn inner_run(code_patch: Option) { { let path = format!("crates/s3s/src/xml/generated{suffix}.rs"); - write_file(&path, || xml::codegen(&ops, &rust_types)); + write_file(&path, || xml::codegen(&ops, &rust_types, code_patch)); } { @@ -86,7 +86,7 @@ fn inner_run(code_patch: Option) { { let path = format!("crates/s3s/src/ops/generated{suffix}.rs"); - write_file(&path, || ops::codegen(&ops, &rust_types)); + write_file(&path, || ops::codegen(&ops, &rust_types, code_patch)); } { diff --git a/codegen/src/v1/ops.rs b/codegen/src/v1/ops.rs index f8a5c603..2fc6e3f5 100644 --- a/codegen/src/v1/ops.rs +++ b/codegen/src/v1/ops.rs @@ -5,6 +5,7 @@ use super::{dto, rust, smithy}; use super::{headers, o}; use crate::declare_codegen; +use crate::v1::Patch; use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -151,7 +152,7 @@ pub fn is_op_output(name: &str, ops: &Operations) -> bool { name.strip_suffix("Output").is_some_and(|x| ops.contains_key(x)) } -pub fn codegen(ops: &Operations, rust_types: &RustTypes) { +pub fn codegen(ops: &Operations, rust_types: &RustTypes, patch: Option) { declare_codegen!(); for op in ops.values() { @@ -178,7 +179,7 @@ pub fn codegen(ops: &Operations, rust_types: &RustTypes) { "", ]); - codegen_http(ops, rust_types); + codegen_http(ops, rust_types, patch); codegen_router(ops, rust_types); } @@ -190,7 +191,7 @@ fn status_code_name(code: u16) -> &'static str { } } -fn codegen_http(ops: &Operations, rust_types: &RustTypes) { +fn codegen_http(ops: &Operations, rust_types: &RustTypes, patch: Option) { codegen_header_value(ops, rust_types); for op in ops.values() { @@ -202,7 +203,7 @@ fn codegen_http(ops: &Operations, rust_types: &RustTypes) { g!("impl {} {{", op.name); - codegen_op_http_de(op, rust_types); + codegen_op_http_de(op, rust_types, patch); codegen_op_http_ser(op, rust_types); g!("}}"); @@ -563,7 +564,7 @@ fn codegen_op_http_ser(op: &Operation, rust_types: &RustTypes) { } #[allow(clippy::too_many_lines)] -fn codegen_op_http_de(op: &Operation, rust_types: &RustTypes) { +fn codegen_op_http_de(op: &Operation, rust_types: &RustTypes, patch: Option) { let input = op.input.as_str(); let rust_type = &rust_types[input]; match rust_type { @@ -708,9 +709,29 @@ fn codegen_op_http_de(op: &Operation, rust_types: &RustTypes) { g!(" Err(e) => return Err(e),"); g!("}};"); } else if field.option_type { - g!("let {}: Option<{}> = http::take_opt_xml_body(req)?;", field.name, field.type_); + // MinIO compatibility: literal " Enabled " for legacy config + if op.name == "PutObjectLockConfiguration" + && field.name == "object_lock_configuration" + && matches!(patch, Some(Patch::Minio)) + { + g!( + "let {}: Option<{}> = http::take_opt_object_lock_configuration(req)?;", + field.name, + field.type_ + ); + } else { + g!("let {}: Option<{}> = http::take_opt_xml_body(req)?;", field.name, field.type_); + } } else { - g!("let {}: {} = http::take_xml_body(req)?;", field.name, field.type_); + // MinIO compatibility: literal " Enabled " for legacy config + if op.name == "PutBucketVersioning" + && field.name == "versioning_configuration" + && matches!(patch, Some(Patch::Minio)) + { + g!("let {}: {} = http::take_versioning_configuration(req)?;", field.name, field.type_); + } else { + g!("let {}: {} = http::take_xml_body(req)?;", field.name, field.type_); + } } } }, diff --git a/codegen/src/v1/xml.rs b/codegen/src/v1/xml.rs index bbc30cf7..1924f2fb 100644 --- a/codegen/src/v1/xml.rs +++ b/codegen/src/v1/xml.rs @@ -4,6 +4,7 @@ use super::rust; use super::rust::default_value_literal; use crate::declare_codegen; +use crate::v1::Patch; use crate::v1::ops::is_op_output; use crate::v1::rust::StructField; @@ -13,7 +14,7 @@ use std::ops::Not; use scoped_writer::g; use stdx::default::default; -pub fn codegen(ops: &Operations, rust_types: &RustTypes) { +pub fn codegen(ops: &Operations, rust_types: &RustTypes, patch: Option) { declare_codegen!(); g([ @@ -63,8 +64,8 @@ pub fn codegen(ops: &Operations, rust_types: &RustTypes) { g!("const XMLNS_S3: &str = \"http://s3.amazonaws.com/doc/2006-03-01/\";"); g!(); - codegen_xml_serde(ops, rust_types, &root_type_names); - codegen_xml_serde_content(ops, rust_types, &field_type_names); + codegen_xml_serde(ops, rust_types, &root_type_names, patch); + codegen_xml_serde_content(ops, rust_types, &field_type_names, patch); } pub fn is_xml_payload(field: &rust::StructField) -> bool { @@ -220,7 +221,12 @@ fn s3_unwrapped_xml_output(ops: &Operations, ty_name: &str) -> bool { ops.iter().any(|(_, op)| op.s3_unwrapped_xml_output && op.output == ty_name) } -fn codegen_xml_serde(ops: &Operations, rust_types: &RustTypes, root_type_names: &BTreeMap<&str, Option<&str>>) { +fn codegen_xml_serde( + ops: &Operations, + rust_types: &RustTypes, + root_type_names: &BTreeMap<&str, Option<&str>>, + patch: Option, +) { for (rust_type, xml_name) in root_type_names.iter().map(|(&name, xml_name)| (&rust_types[name], xml_name)) { let rust::Type::Struct(ty) = rust_type else { panic!("{rust_type:#?}") }; @@ -253,7 +259,15 @@ fn codegen_xml_serde(ops: &Operations, rust_types: &RustTypes, root_type_names: g!("impl<'xml> Deserialize<'xml> for {} {{", ty.name); g!("fn deserialize(d: &mut Deserializer<'xml>) -> DeResult {{"); - g!("d.named_element(\"{xml_name}\", Deserializer::content)"); + // MinIO compatibility: accept both LifecycleConfiguration and BucketLifecycleConfiguration + if ty.name == "BucketLifecycleConfiguration" && matches!(patch, Some(Patch::Minio)) { + g!("d.named_element_any("); + g!(" &[\"LifecycleConfiguration\", \"BucketLifecycleConfiguration\"],"); + g!(" Deserializer::content,"); + g!(")"); + } else { + g!("d.named_element(\"{xml_name}\", Deserializer::content)"); + } g!("}}"); g!("}}"); @@ -262,7 +276,7 @@ fn codegen_xml_serde(ops: &Operations, rust_types: &RustTypes, root_type_names: } } -fn codegen_xml_serde_content(ops: &Operations, rust_types: &RustTypes, field_type_names: &BTreeSet<&str>) { +fn codegen_xml_serde_content(ops: &Operations, rust_types: &RustTypes, field_type_names: &BTreeSet<&str>, patch: Option) { for rust_type in field_type_names.iter().map(|&name| &rust_types[name]) { match rust_type { rust::Type::Alias(_) => {} @@ -329,13 +343,13 @@ fn codegen_xml_serde_content(ops: &Operations, rust_types: &RustTypes, field_typ g!("}}"); } } - rust::Type::Struct(ty) => codegen_xml_serde_content_struct(ops, rust_types, ty), + rust::Type::Struct(ty) => codegen_xml_serde_content_struct(ops, rust_types, ty, patch), } } } #[allow(clippy::too_many_lines)] -fn codegen_xml_serde_content_struct(_ops: &Operations, rust_types: &RustTypes, ty: &rust::Struct) { +fn codegen_xml_serde_content_struct(_ops: &Operations, rust_types: &RustTypes, ty: &rust::Struct, patch: Option) { if can_impl_serialize_content(rust_types, &ty.name) { g!("impl SerializeContent for {} {{", ty.name); g!( @@ -537,7 +551,12 @@ fn codegen_xml_serde_content_struct(_ops: &Operations, rust_types: &RustTypes, t g!("Ok(())"); g!("}}"); } - g!("_ => Err(DeError::UnexpectedTagName)"); + // MinIO compatibility: skip unknown elements for BucketLifecycleConfiguration + if ty.name == "BucketLifecycleConfiguration" && matches!(patch, Some(Patch::Minio)) { + g!("_ => Ok(()),"); + } else { + g!("_ => Err(DeError::UnexpectedTagName)"); + } g!("}})?;"); } diff --git a/crates/s3s-aws/src/conv/generated_minio.rs b/crates/s3s-aws/src/conv/generated_minio.rs index c39124d3..83ed18c8 100644 --- a/crates/s3s-aws/src/conv/generated_minio.rs +++ b/crates/s3s-aws/src/conv/generated_minio.rs @@ -366,6 +366,7 @@ impl AwsConversion for s3s::dto::BucketLifecycleConfiguration { fn try_from_aws(x: Self::Target) -> S3Result { Ok(Self { + expiry_updated_at: None, rules: try_from_aws(x.rules)?, }) } @@ -4597,6 +4598,7 @@ impl AwsConversion for s3s::dto::LifecycleExpiration { Ok(Self { date: try_from_aws(x.date)?, days: try_from_aws(x.days)?, + expired_object_all_versions: None, expired_object_delete_marker: try_from_aws(x.expired_object_delete_marker)?, }) } @@ -4618,6 +4620,7 @@ impl AwsConversion for s3s::dto::LifecycleRule { fn try_from_aws(x: Self::Target) -> S3Result { Ok(Self { abort_incomplete_multipart_upload: try_from_aws(x.abort_incomplete_multipart_upload)?, + del_marker_expiration: None, expiration: try_from_aws(x.expiration)?, filter: try_from_aws(x.filter)?, id: try_from_aws(x.id)?, diff --git a/crates/s3s/src/dto/generated_minio.rs b/crates/s3s/src/dto/generated_minio.rs index 4879d5ba..a5cf1ea2 100644 --- a/crates/s3s/src/dto/generated_minio.rs +++ b/crates/s3s/src/dto/generated_minio.rs @@ -660,6 +660,7 @@ pub type BucketKeyEnabled = bool; /// in the Amazon S3 User Guide.

#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] pub struct BucketLifecycleConfiguration { + pub expiry_updated_at: Option, ///

A lifecycle rule for individual objects in an Amazon S3 bucket.

pub rules: LifecycleRules, } @@ -667,6 +668,9 @@ pub struct BucketLifecycleConfiguration { impl fmt::Debug for BucketLifecycleConfiguration { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut d = f.debug_struct("BucketLifecycleConfiguration"); + if let Some(ref val) = self.expiry_updated_at { + d.field("expiry_updated_at", val); + } d.field("rules", &self.rules); d.finish_non_exhaustive() } @@ -3957,6 +3961,21 @@ impl fmt::Debug for DefaultRetention { } } +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct DelMarkerExpiration { + pub days: Option, +} + +impl fmt::Debug for DelMarkerExpiration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut d = f.debug_struct("DelMarkerExpiration"); + if let Some(ref val) = self.days { + d.field("days", val); + } + d.finish_non_exhaustive() + } +} + ///

Container for the objects to delete.

#[derive(Clone, Default, PartialEq)] pub struct Delete { @@ -7539,6 +7558,8 @@ impl FromStr for ExpirationStatus { } } +pub type ExpiredObjectAllVersions = bool; + pub type ExpiredObjectDeleteMarker = bool; pub type Expires = Timestamp; @@ -11653,6 +11674,7 @@ pub struct LifecycleExpiration { ///

Indicates the lifetime, in days, of the objects that are subject to the rule. The value /// must be a non-zero positive integer.

pub days: Option, + pub expired_object_all_versions: Option, ///

Indicates whether Amazon S3 will remove a delete marker with no noncurrent versions. If set /// to true, the delete marker will be expired; if set to false the policy takes no action. /// This cannot be specified with Days or Date in a Lifecycle Expiration Policy.

@@ -11672,6 +11694,9 @@ impl fmt::Debug for LifecycleExpiration { if let Some(ref val) = self.days { d.field("days", val); } + if let Some(ref val) = self.expired_object_all_versions { + d.field("expired_object_all_versions", val); + } if let Some(ref val) = self.expired_object_delete_marker { d.field("expired_object_delete_marker", val); } @@ -11685,6 +11710,7 @@ impl fmt::Debug for LifecycleExpiration { #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct LifecycleRule { pub abort_incomplete_multipart_upload: Option, + pub del_marker_expiration: Option, ///

Specifies the expiration for the lifecycle of the object in the form of date, days and, /// whether the object has a delete marker.

pub expiration: Option, @@ -11735,6 +11761,9 @@ impl fmt::Debug for LifecycleRule { if let Some(ref val) = self.abort_incomplete_multipart_upload { d.field("abort_incomplete_multipart_upload", val); } + if let Some(ref val) = self.del_marker_expiration { + d.field("del_marker_expiration", val); + } if let Some(ref val) = self.expiration { d.field("expiration", val); } @@ -34677,6 +34706,9 @@ impl DtoExt for DefaultRetention { } } } +impl DtoExt for DelMarkerExpiration { + fn ignore_empty_strings(&mut self) {} +} impl DtoExt for Delete { fn ignore_empty_strings(&mut self) {} } @@ -35972,6 +36004,9 @@ impl DtoExt for LifecycleRule { if let Some(ref mut val) = self.abort_incomplete_multipart_upload { val.ignore_empty_strings(); } + if let Some(ref mut val) = self.del_marker_expiration { + val.ignore_empty_strings(); + } if let Some(ref mut val) = self.expiration { val.ignore_empty_strings(); } diff --git a/crates/s3s/src/http/de.rs b/crates/s3s/src/http/de.rs index ec9156a5..11d71f72 100644 --- a/crates/s3s/src/http/de.rs +++ b/crates/s3s/src/http/de.rs @@ -258,6 +258,57 @@ where result } +/// `MinIO` compatibility: literal `" Enabled "` (with spaces) for legacy object lock/versioning config. +#[cfg(feature = "minio")] +fn is_minio_enabled_literal(bytes: &[u8]) -> bool { + bytes.trim_ascii() == b"Enabled" +} + +/// `MinIO` compatibility: take `ObjectLockConfiguration`, accepting literal `" Enabled "` as enabled. +#[cfg(feature = "minio")] +pub fn take_opt_object_lock_configuration(req: &mut Request) -> S3Result> { + use crate::dto::{ObjectLockConfiguration, ObjectLockEnabled}; + + let bytes = req.body.take_bytes().expect("full body not found"); + if bytes.is_empty() { + return Ok(None); + } + if is_minio_enabled_literal(&bytes) { + return Ok(Some(ObjectLockConfiguration { + object_lock_enabled: Some(ObjectLockEnabled::from("Enabled".to_owned())), + rule: None, + })); + } + let result = deserialize_xml::(&bytes).map(Some); + if result.is_err() { + error!(?bytes, "malformed xml body"); + } + result +} + +/// `MinIO` compatibility: take `VersioningConfiguration`, accepting literal `" Enabled "` as enabled. +#[cfg(feature = "minio")] +pub fn take_versioning_configuration(req: &mut Request) -> S3Result { + use crate::dto::{BucketVersioningStatus, VersioningConfiguration}; + use stdx::default::default; + + let bytes = req.body.take_bytes().expect("full body not found"); + if bytes.is_empty() { + return Err(S3ErrorCode::MissingRequestBodyError.into()); + } + if is_minio_enabled_literal(&bytes) { + return Ok(VersioningConfiguration { + status: Some(BucketVersioningStatus::from("Enabled".to_owned())), + ..default() + }); + } + let result = deserialize_xml::(&bytes); + if result.is_err() { + error!(?bytes, "malformed xml body"); + } + result +} + pub fn take_string_body(req: &mut Request) -> S3Result { let bytes = req.body.take_bytes().expect("full body not found"); match String::from_utf8_simd(bytes.into()) { diff --git a/crates/s3s/src/ops/generated_minio.rs b/crates/s3s/src/ops/generated_minio.rs index 6d045216..e0f6ab1f 100644 --- a/crates/s3s/src/ops/generated_minio.rs +++ b/crates/s3s/src/ops/generated_minio.rs @@ -5345,7 +5345,7 @@ impl PutBucketVersioning { let mfa: Option = http::parse_opt_header(req, &X_AMZ_MFA)?; - let versioning_configuration: VersioningConfiguration = http::take_xml_body(req)?; + let versioning_configuration: VersioningConfiguration = http::take_versioning_configuration(req)?; Ok(PutBucketVersioningInput { bucket, @@ -5943,7 +5943,7 @@ impl PutObjectLockConfiguration { let expected_bucket_owner: Option = http::parse_opt_header(req, &X_AMZ_EXPECTED_BUCKET_OWNER)?; - let object_lock_configuration: Option = http::take_opt_xml_body(req)?; + let object_lock_configuration: Option = http::take_opt_object_lock_configuration(req)?; let request_payer: Option = http::parse_opt_header(req, &X_AMZ_REQUEST_PAYER)?; diff --git a/crates/s3s/src/xml/de.rs b/crates/s3s/src/xml/de.rs index e0402953..a0a82cca 100644 --- a/crates/s3s/src/xml/de.rs +++ b/crates/s3s/src/xml/de.rs @@ -175,8 +175,28 @@ impl<'xml> Deserializer<'xml> { } } - /// Expects an end event - fn expect_end(&mut self, name: &[u8]) -> DeResult { + /// Expects a start event with any of the given names. Returns the matched name (owned). + fn expect_start_any(&mut self, names: &[&str]) -> DeResult> { + let names_bytes: Vec> = names.iter().map(|s| s.as_bytes().to_vec()).collect(); + loop { + match self.next_event()? { + DeEvent::Start(x) => { + let name = x.name().as_ref().to_vec(); + if names_bytes.contains(&name) { + return Ok(name); + } + return Err(unexpected_tag_name()); + } + DeEvent::End(_) => return Err(unexpected_end()), + DeEvent::Text(_) => continue, + DeEvent::Eof => return Err(unexpected_eof()), + } + } + } + + /// Expects an end event (accepts both `&[u8]` and `AsRef<[u8]>` for flexibility) + fn expect_end(&mut self, name: impl AsRef<[u8]>) -> DeResult { + let name = name.as_ref(); loop { match self.next_event()? { DeEvent::Start(_) => return Err(unexpected_start()), @@ -215,6 +235,17 @@ impl<'xml> Deserializer<'xml> { Ok(ans) } + /// Deserializes an element with any of the given root names (`MinIO` compatibility). + /// + /// # Errors + /// Returns an error if the deserialization fails. + pub fn named_element_any(&mut self, names: &[&str], f: impl FnOnce(&mut Self) -> DeResult) -> DeResult { + let name = self.expect_start_any(names)?; + let ans = f(self)?; + self.expect_end(name)?; + Ok(ans) + } + pub fn element(&mut self, f: impl FnOnce(&mut Self, &[u8]) -> DeResult) -> DeResult { loop { match self.peek_event()? { diff --git a/crates/s3s/src/xml/generated_minio.rs b/crates/s3s/src/xml/generated_minio.rs index f6b8d23d..8d73d5b5 100644 --- a/crates/s3s/src/xml/generated_minio.rs +++ b/crates/s3s/src/xml/generated_minio.rs @@ -260,6 +260,8 @@ use std::io::Write; // DeserializeContent: DaysAfterInitiation // SerializeContent: DefaultRetention // DeserializeContent: DefaultRetention +// SerializeContent: DelMarkerExpiration +// DeserializeContent: DelMarkerExpiration // SerializeContent: Delete // DeserializeContent: Delete // SerializeContent: DeleteMarker @@ -327,6 +329,8 @@ use std::io::Write; // DeserializeContent: ExistingObjectReplicationStatus // SerializeContent: ExpirationStatus // DeserializeContent: ExpirationStatus +// SerializeContent: ExpiredObjectAllVersions +// DeserializeContent: ExpiredObjectAllVersions // SerializeContent: ExpiredObjectDeleteMarker // DeserializeContent: ExpiredObjectDeleteMarker // SerializeContent: ExposeHeader @@ -875,7 +879,7 @@ impl Serialize for BucketLifecycleConfiguration { impl<'xml> Deserialize<'xml> for BucketLifecycleConfiguration { fn deserialize(d: &mut Deserializer<'xml>) -> DeResult { - d.named_element("LifecycleConfiguration", Deserializer::content) + d.named_element_any(&["LifecycleConfiguration", "BucketLifecycleConfiguration"], Deserializer::content) } } @@ -1994,6 +1998,9 @@ impl<'xml> DeserializeContent<'xml> for BucketInfo { } impl SerializeContent for BucketLifecycleConfiguration { fn serialize_content(&self, s: &mut Serializer) -> SerResult { + if let Some(ref val) = self.expiry_updated_at { + s.timestamp("ExpiryUpdatedAt", val, TimestampFormat::DateTime)?; + } { let iter = &self.rules; s.flattened_list("Rule", iter)?; @@ -2004,16 +2011,25 @@ impl SerializeContent for BucketLifecycleConfiguration { impl<'xml> DeserializeContent<'xml> for BucketLifecycleConfiguration { fn deserialize_content(d: &mut Deserializer<'xml>) -> DeResult { + let mut expiry_updated_at: Option = None; let mut rules: Option = None; d.for_each_element(|d, x| match x { + b"ExpiryUpdatedAt" => { + if expiry_updated_at.is_some() { + return Err(DeError::DuplicateField); + } + expiry_updated_at = Some(d.timestamp(TimestampFormat::DateTime)?); + Ok(()) + } b"Rule" => { let ans: LifecycleRule = d.content()?; rules.get_or_insert_with(List::new).push(ans); Ok(()) } - _ => Err(DeError::UnexpectedTagName), + _ => Ok(()), })?; Ok(Self { + expiry_updated_at, rules: rules.ok_or(DeError::MissingField)?, }) } @@ -3189,6 +3205,31 @@ impl<'xml> DeserializeContent<'xml> for DefaultRetention { Ok(Self { days, mode, years }) } } +impl SerializeContent for DelMarkerExpiration { + fn serialize_content(&self, s: &mut Serializer) -> SerResult { + if let Some(ref val) = self.days { + s.content("Days", val)?; + } + Ok(()) + } +} + +impl<'xml> DeserializeContent<'xml> for DelMarkerExpiration { + fn deserialize_content(d: &mut Deserializer<'xml>) -> DeResult { + let mut days: Option = None; + d.for_each_element(|d, x| match x { + b"Days" => { + if days.is_some() { + return Err(DeError::DuplicateField); + } + days = Some(d.content()?); + Ok(()) + } + _ => Err(DeError::UnexpectedTagName), + })?; + Ok(Self { days }) + } +} impl SerializeContent for Delete { fn serialize_content(&self, s: &mut Serializer) -> SerResult { { @@ -5384,6 +5425,9 @@ impl SerializeContent for LifecycleExpiration { if let Some(ref val) = self.days { s.content("Days", val)?; } + if let Some(ref val) = self.expired_object_all_versions { + s.content("ExpiredObjectAllVersions", val)?; + } if let Some(ref val) = self.expired_object_delete_marker { s.content("ExpiredObjectDeleteMarker", val)?; } @@ -5395,6 +5439,7 @@ impl<'xml> DeserializeContent<'xml> for LifecycleExpiration { fn deserialize_content(d: &mut Deserializer<'xml>) -> DeResult { let mut date: Option = None; let mut days: Option = None; + let mut expired_object_all_versions: Option = None; let mut expired_object_delete_marker: Option = None; d.for_each_element(|d, x| match x { b"Date" => { @@ -5411,6 +5456,13 @@ impl<'xml> DeserializeContent<'xml> for LifecycleExpiration { days = Some(d.content()?); Ok(()) } + b"ExpiredObjectAllVersions" => { + if expired_object_all_versions.is_some() { + return Err(DeError::DuplicateField); + } + expired_object_all_versions = Some(d.content()?); + Ok(()) + } b"ExpiredObjectDeleteMarker" => { if expired_object_delete_marker.is_some() { return Err(DeError::DuplicateField); @@ -5423,6 +5475,7 @@ impl<'xml> DeserializeContent<'xml> for LifecycleExpiration { Ok(Self { date, days, + expired_object_all_versions, expired_object_delete_marker, }) } @@ -5432,6 +5485,9 @@ impl SerializeContent for LifecycleRule { if let Some(ref val) = self.abort_incomplete_multipart_upload { s.content("AbortIncompleteMultipartUpload", val)?; } + if let Some(ref val) = self.del_marker_expiration { + s.content("DelMarkerExpiration", val)?; + } if let Some(ref val) = self.expiration { s.content("Expiration", val)?; } @@ -5461,6 +5517,7 @@ impl SerializeContent for LifecycleRule { impl<'xml> DeserializeContent<'xml> for LifecycleRule { fn deserialize_content(d: &mut Deserializer<'xml>) -> DeResult { let mut abort_incomplete_multipart_upload: Option = None; + let mut del_marker_expiration: Option = None; let mut expiration: Option = None; let mut filter: Option = None; let mut id: Option = None; @@ -5477,6 +5534,13 @@ impl<'xml> DeserializeContent<'xml> for LifecycleRule { abort_incomplete_multipart_upload = Some(d.content()?); Ok(()) } + b"DelMarkerExpiration" => { + if del_marker_expiration.is_some() { + return Err(DeError::DuplicateField); + } + del_marker_expiration = Some(d.content()?); + Ok(()) + } b"Expiration" => { if expiration.is_some() { return Err(DeError::DuplicateField); @@ -5533,6 +5597,7 @@ impl<'xml> DeserializeContent<'xml> for LifecycleRule { })?; Ok(Self { abort_incomplete_multipart_upload, + del_marker_expiration, expiration, filter, id, diff --git a/crates/s3s/tests/xml.rs b/crates/s3s/tests/xml.rs index 20077b2b..e7a2b11f 100644 --- a/crates/s3s/tests/xml.rs +++ b/crates/s3s/tests/xml.rs @@ -199,9 +199,8 @@ fn tagging() { #[test] fn lifecycle_expiration() { let val = s3s::dto::LifecycleExpiration { - date: None, days: Some(365), - expired_object_delete_marker: None, + ..Default::default() }; let ans = serialize_content(&val).unwrap(); @@ -285,6 +284,37 @@ fn assume_role_output() { test_serde(&val); } +#[cfg(feature = "minio")] +#[test] +fn minio_bucket_lifecycle_configuration_root() { + // MinIO compatibility: accept both LifecycleConfiguration and BucketLifecycleConfiguration + let xml_bucket = r" + + + r1 + Enabled + 30 + + + "; + let val = deserialize::(xml_bucket.as_bytes()).unwrap(); + assert_eq!(val.rules.len(), 1); + assert_eq!(val.rules[0].id.as_deref(), Some("r1")); + + let xml_std = r" + + + r2 + Enabled + 30 + + + "; + let val2 = deserialize::(xml_std.as_bytes()).unwrap(); + assert_eq!(val2.rules.len(), 1); + assert_eq!(val2.rules[0].id.as_deref(), Some("r2")); +} + #[cfg(feature = "minio")] #[test] fn minio_versioning_configuration() { diff --git a/data/minio-patches.json b/data/minio-patches.json index 1de7eee3..813dab89 100644 --- a/data/minio-patches.json +++ b/data/minio-patches.json @@ -173,6 +173,59 @@ } } } + }, + "com.amazonaws.s3#DelMarkerExpiration": { + "type": "structure", + "members": { + "Days": { + "target": "com.amazonaws.s3#Days" + } + }, + "traits": { + "s3s#minio": "" + } + }, + "com.amazonaws.s3#BucketLifecycleConfiguration": { + "type": "structure", + "members": { + "ExpiryUpdatedAt": { + "target": "com.amazonaws.s3#Date", + "traits": { + "smithy.api#xmlName": "ExpiryUpdatedAt", + "s3s#minio": "" + } + } + } + }, + "com.amazonaws.s3#LifecycleRule": { + "type": "structure", + "members": { + "DelMarkerExpiration": { + "target": "com.amazonaws.s3#DelMarkerExpiration", + "traits": { + "smithy.api#xmlName": "DelMarkerExpiration", + "s3s#minio": "" + } + } + } + }, + "com.amazonaws.s3#ExpiredObjectAllVersions": { + "type": "boolean", + "traits": { + "s3s#minio": "" + } + }, + "com.amazonaws.s3#LifecycleExpiration": { + "type": "structure", + "members": { + "ExpiredObjectAllVersions": { + "target": "com.amazonaws.s3#ExpiredObjectAllVersions", + "traits": { + "smithy.api#xmlName": "ExpiredObjectAllVersions", + "s3s#minio": "" + } + } + } } } } \ No newline at end of file