From 780f3899734d1266abe7957719a4492e13bd2c1c Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Thu, 29 Jan 2026 00:09:54 +0100 Subject: [PATCH 01/10] test: replace some unwrap with expect in tests this add more information in case of failure --- src/api/s3/cors.rs | 3 ++- src/api/s3/website.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/api/s3/cors.rs b/src/api/s3/cors.rs index d746ff8d..1e3f6a0d 100644 --- a/src/api/s3/cors.rs +++ b/src/api/s3/cors.rs @@ -228,7 +228,8 @@ mod tests { * "#; - let conf: CorsConfiguration = from_str(message).unwrap(); + let conf: CorsConfiguration = + from_str(message).expect("failed to deserialize xml into `CorsConfiguration` struct"); let ref_value = CorsConfiguration { xmlns: (), cors_rules: vec![ diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 18da3670..d4f0e37c 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -416,7 +416,8 @@ mod tests { "#; - let conf: WebsiteConfiguration = from_str(message).unwrap(); + let conf: WebsiteConfiguration = + from_str(message).expect("failed to deserialize xml in `WebsiteConfiguration`"); let ref_value = WebsiteConfiguration { xmlns: (), error_document: Some(Key { From 507be6089099fe4be4d6b1018448e057b3168708 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Thu, 5 Feb 2026 13:25:12 +0100 Subject: [PATCH 02/10] test: use assert_eq instead of assert to improve failed output --- src/garage/tests/s3/multipart.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/garage/tests/s3/multipart.rs b/src/garage/tests/s3/multipart.rs index cd1c754a..fa129dfd 100644 --- a/src/garage/tests/s3/multipart.rs +++ b/src/garage/tests/s3/multipart.rs @@ -466,7 +466,7 @@ async fn test_uploadlistpart() { .await .unwrap(); - assert!(r.part_number_marker.is_none()); + assert_eq!(r.part_number_marker.as_deref(), None); assert_eq!(r.next_part_number_marker.as_deref(), Some("1")); assert_eq!(r.max_parts.unwrap(), 1_i32); assert!(r.is_truncated.unwrap()); From 93cd71eb72970bff40c6ebf716a3748dfd146fe2 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Thu, 5 Feb 2026 01:05:56 +0100 Subject: [PATCH 03/10] test: add unprettify_xml helper and use it this replace previous cleanup which remove space between `element` and `attribute` name in xml --- src/api/s3/cors.rs | 5 +++-- src/api/s3/lib.rs | 8 ++++++++ src/api/s3/lifecycle.rs | 7 ++++--- src/api/s3/website.rs | 5 +++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/api/s3/cors.rs b/src/api/s3/cors.rs index 1e3f6a0d..4e1d8ece 100644 --- a/src/api/s3/cors.rs +++ b/src/api/s3/cors.rs @@ -196,6 +196,8 @@ impl CorsRule { #[cfg(test)] mod tests { + use crate::unprettify_xml; + use super::*; use quick_xml::de::from_str; @@ -266,8 +268,7 @@ mod tests { let message2 = to_xml_with_header(&ref_value)?; - let cleanup = |c: &str| c.replace(char::is_whitespace, ""); - assert_eq!(cleanup(message), cleanup(&message2)); + assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); Ok(()) } diff --git a/src/api/s3/lib.rs b/src/api/s3/lib.rs index 83f684f8..f5fab57a 100644 --- a/src/api/s3/lib.rs +++ b/src/api/s3/lib.rs @@ -19,3 +19,11 @@ pub mod website; mod encryption; mod router; pub mod xml; + +#[cfg(test)] +pub(crate) fn unprettify_xml(xml_in: &str) -> String { + xml_in.trim().lines().fold(String::new(), |mut val, line| { + val.push_str(line.trim()); + val + }) +} diff --git a/src/api/s3/lifecycle.rs b/src/api/s3/lifecycle.rs index ccda6cfd..ba6aeda2 100644 --- a/src/api/s3/lifecycle.rs +++ b/src/api/s3/lifecycle.rs @@ -285,6 +285,8 @@ impl Expiration { #[cfg(test)] mod tests { + use crate::unprettify_xml; + use super::*; use quick_xml::de::from_str; @@ -357,8 +359,7 @@ mod tests { let message2 = to_xml_with_header(&ref_value)?; - let cleanup = |c: &str| c.replace(char::is_whitespace, ""); - assert_eq!(cleanup(message), cleanup(&message2)); + assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); // Check validation let validated = ref_value @@ -393,7 +394,7 @@ mod tests { let message3 = to_xml_with_header(&LifecycleConfiguration::from_garage_lifecycle_config( &validated, ))?; - assert_eq!(cleanup(message), cleanup(&message3)); + assert_eq!(unprettify_xml(message), unprettify_xml(&message3)); Ok(()) } diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index d4f0e37c..506df69e 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -373,6 +373,8 @@ impl Redirect { #[cfg(test)] mod tests { + use crate::unprettify_xml; + use super::*; use quick_xml::de::from_str; @@ -468,8 +470,7 @@ mod tests { let message2 = to_xml_with_header(&ref_value)?; - let cleanup = |c: &str| c.replace(char::is_whitespace, ""); - assert_eq!(cleanup(message), cleanup(&message2)); + assert_eq!(unprettify_xml(message), unprettify_xml(&message2)); Ok(()) } From 3c5018bd6bd0d8e2dc7cf3aa81781b82a36d2ee1 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Mon, 16 Feb 2026 15:09:03 +0100 Subject: [PATCH 04/10] refactor: use str trim result to parse xml - use `trim` method of `str` instead of manual implementation with `trim_matches(char::is_whitespace)` - use result of `trim` for xml parsing instead of use the `str` before trim. --- src/api/s3/bucket.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/s3/bucket.rs b/src/api/s3/bucket.rs index 4abc614f..901961e9 100644 --- a/src/api/s3/bucket.rs +++ b/src/api/s3/bucket.rs @@ -330,8 +330,8 @@ fn parse_create_bucket_xml(xml_bytes: &[u8]) -> Option> { // Returns Some(None) if no location constraint is given // Returns Some(Some("xxxx")) where xxxx is the given location constraint - let xml_str = std::str::from_utf8(xml_bytes).ok()?; - if xml_str.trim_matches(char::is_whitespace).is_empty() { + let xml_str = std::str::from_utf8(xml_bytes).ok()?.trim(); + if xml_str.is_empty() { return Some(None); } @@ -373,6 +373,7 @@ mod tests { #[test] fn create_bucket() { assert_eq!(parse_create_bucket_xml(br#""#), Some(None)); + assert_eq!(parse_create_bucket_xml(br#" "#), Some(None)); assert_eq!( parse_create_bucket_xml( br#" From 674c2c1cb1284f482f0fbb3f941c9ca232e0e771 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Thu, 29 Jan 2026 00:54:09 +0100 Subject: [PATCH 05/10] chore: update quick-xml dep rework associated error, and fixup error for XML serialization. Should be InternalError/INTERNAL_SERVER_ERROR not MalformedXML/BAD_REQUEST --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/api/s3/error.rs | 28 ++++++++++++++-------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 482db25c..5780ca65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3737,9 +3737,9 @@ checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" [[package]] name = "quick-xml" -version = "0.26.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" +checksum = "f2e3bf4aa9d243beeb01a7b3bc30b77cfe2c44e24ec02d751a7104a53c2c49a1" dependencies = [ "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index c6da24c5..f56999f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ fjall = "2.11" async-compression = { version = "0.4", features = ["tokio", "zstd"] } zstd = { version = "0.13", default-features = false } -quick-xml = { version = "0.26", features = ["serialize"] } +quick-xml = { version = "0.39", features = ["serialize"] } rmp-serde = "1.3" serde = { version = "1.0", default-features = false, features = ["derive", "rc"] } serde_bytes = "0.11" diff --git a/src/api/s3/error.rs b/src/api/s3/error.rs index 58badf21..1b1a75f8 100644 --- a/src/api/s3/error.rs +++ b/src/api/s3/error.rs @@ -69,8 +69,16 @@ pub enum Error { InvalidUtf8String(#[from] std::string::FromUtf8Error), /// The client sent invalid XML data - #[error("Invalid XML: {0}")] - InvalidXml(String), + #[error("failed to deserialize XML")] + InvalidXml(#[from] roxmltree::Error), + + /// The client sent invalid XML data + #[error("XML deserialization failed")] + InvalidXmlDe(#[from] quick_xml::de::DeError), + + /// The server failed to serialize data into XML + #[error("failed to serialize XML")] + InvalidXmlSe(#[from] quick_xml::se::SeError), /// The client sent a range header with invalid value #[error("Invalid HTTP range: {0:?}")] @@ -105,18 +113,6 @@ impl From<(http_range::HttpRangeParseError, u64)> for Error { } } -impl From for Error { - fn from(err: roxmltree::Error) -> Self { - Self::InvalidXml(format!("{}", err)) - } -} - -impl From for Error { - fn from(err: quick_xml::de::DeError) -> Self { - Self::InvalidXml(format!("{}", err)) - } -} - impl From for Error { fn from(err: SignatureError) -> Self { match err { @@ -149,6 +145,8 @@ impl Error { Error::AuthorizationHeaderMalformed(_) => "AuthorizationHeaderMalformed", Error::NotImplemented(_) => "NotImplemented", Error::InvalidXml(_) => "MalformedXML", + Error::InvalidXmlDe(_) => "MalformedXML", + Error::InvalidXmlSe(_) => "InternalError", Error::InvalidRange(_) => "InvalidRange", Error::InvalidDigest(_) => "InvalidDigest", Error::InvalidUtf8Str(_) | Error::InvalidUtf8String(_) => "InvalidRequest", @@ -166,6 +164,7 @@ impl ApiError for Error { Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED, Error::InvalidRange(_) => StatusCode::RANGE_NOT_SATISFIABLE, Error::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED, + Error::InvalidXmlSe(_) => StatusCode::INTERNAL_SERVER_ERROR, Error::AuthorizationHeaderMalformed(_) | Error::InvalidPart | Error::InvalidPartOrder @@ -173,6 +172,7 @@ impl ApiError for Error { | Error::InvalidDigest(_) | Error::InvalidEncryptionAlgorithm(_) | Error::InvalidXml(_) + | Error::InvalidXmlDe(_) | Error::InvalidUtf8Str(_) | Error::InvalidUtf8String(_) => StatusCode::BAD_REQUEST, } From d80096de921b332cdf1b5b5590aefe0249d66868 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Wed, 4 Feb 2026 17:52:14 +0100 Subject: [PATCH 06/10] fixup: fix xml attibute `xmlns` serialization rename var with "@xmlns" --- src/api/s3/copy.rs | 2 +- src/api/s3/cors.rs | 2 +- src/api/s3/lifecycle.rs | 2 +- src/api/s3/website.rs | 2 +- src/api/s3/xml.rs | 24 ++++++++++++------------ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/api/s3/copy.rs b/src/api/s3/copy.rs index 7ee1847d..97136096 100644 --- a/src/api/s3/copy.rs +++ b/src/api/s3/copy.rs @@ -853,7 +853,7 @@ pub struct CopyObjectResult { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct CopyPartResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "LastModified")] pub last_modified: s3_xml::Value, diff --git a/src/api/s3/cors.rs b/src/api/s3/cors.rs index 4e1d8ece..ca78ae3f 100644 --- a/src/api/s3/cors.rs +++ b/src/api/s3/cors.rs @@ -86,7 +86,7 @@ pub async fn handle_put_cors( #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename = "CORSConfiguration")] pub struct CorsConfiguration { - #[serde(serialize_with = "xmlns_tag", skip_deserializing)] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] pub xmlns: (), #[serde(rename = "CORSRule")] pub cors_rules: Vec, diff --git a/src/api/s3/lifecycle.rs b/src/api/s3/lifecycle.rs index ba6aeda2..22087183 100644 --- a/src/api/s3/lifecycle.rs +++ b/src/api/s3/lifecycle.rs @@ -83,7 +83,7 @@ pub async fn handle_put_lifecycle( #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct LifecycleConfiguration { - #[serde(serialize_with = "xmlns_tag", skip_deserializing)] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] pub xmlns: (), #[serde(rename = "Rule")] pub lifecycle_rules: Vec, diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 506df69e..c4863e7f 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -110,7 +110,7 @@ pub async fn handle_put_website( #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct WebsiteConfiguration { - #[serde(serialize_with = "xmlns_tag", skip_deserializing)] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)] pub xmlns: (), #[serde(rename = "ErrorDocument")] pub error_document: Option, diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index bfe95fff..2bb265d4 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -61,7 +61,7 @@ pub struct ListAllMyBucketsResult { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct LocationConstraint { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "$value")] pub region: String, @@ -103,7 +103,7 @@ pub struct DeleteError { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct DeleteResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Deleted")] pub deleted: Vec, @@ -113,7 +113,7 @@ pub struct DeleteResult { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct InitiateMultipartUploadResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Bucket")] pub bucket: Value, @@ -125,7 +125,7 @@ pub struct InitiateMultipartUploadResult { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct CompleteMultipartUploadResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Location")] pub location: Option, @@ -175,7 +175,7 @@ pub struct ListMultipartItem { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct ListMultipartUploadsResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Bucket")] pub bucket: Value, @@ -227,7 +227,7 @@ pub struct PartItem { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct ListPartsResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Bucket")] pub bucket: Value, @@ -275,7 +275,7 @@ pub struct CommonPrefix { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct ListBucketResult { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Name")] pub name: Value, @@ -309,7 +309,7 @@ pub struct ListBucketResult { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct VersioningConfiguration { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Status")] pub status: Option, @@ -317,7 +317,7 @@ pub struct VersioningConfiguration { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct PostObject { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Location")] pub location: Value, @@ -331,9 +331,9 @@ pub struct PostObject { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct Grantee { - #[serde(rename = "xmlns:xsi", serialize_with = "xmlns_xsi_tag")] + #[serde(rename = "@xmlns:xsi", serialize_with = "xmlns_xsi_tag")] pub xmlns_xsi: (), - #[serde(rename = "xsi:type")] + #[serde(rename = "@xsi:type")] pub typ: String, #[serde(rename = "DisplayName")] pub display_name: Option, @@ -357,7 +357,7 @@ pub struct AccessControlList { #[derive(Debug, Serialize, PartialEq, Eq)] pub struct AccessControlPolicy { - #[serde(serialize_with = "xmlns_tag")] + #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), #[serde(rename = "Owner")] pub owner: Option, From 1ae4e5d438db4fef49b1f488b8e928cdc0057f4a Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Wed, 4 Feb 2026 18:30:25 +0100 Subject: [PATCH 07/10] fix: mark to skip serialization of `Option` when `None` mark with `skip_serializing_if = "Option::is_none"` --- src/api/s3/cors.rs | 4 +-- src/api/s3/lifecycle.rs | 31 +++++++++++++------ src/api/s3/website.rs | 20 +++++++----- src/api/s3/xml.rs | 68 ++++++++++++++++++++++------------------- 4 files changed, 73 insertions(+), 50 deletions(-) diff --git a/src/api/s3/cors.rs b/src/api/s3/cors.rs index ca78ae3f..9e17bc03 100644 --- a/src/api/s3/cors.rs +++ b/src/api/s3/cors.rs @@ -94,9 +94,9 @@ pub struct CorsConfiguration { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct CorsRule { - #[serde(rename = "ID")] + #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(rename = "MaxAgeSeconds")] + #[serde(rename = "MaxAgeSeconds", skip_serializing_if = "Option::is_none")] pub max_age_seconds: Option, #[serde(rename = "AllowedOrigin")] pub allowed_origins: Vec, diff --git a/src/api/s3/lifecycle.rs b/src/api/s3/lifecycle.rs index 22087183..5f3bfb98 100644 --- a/src/api/s3/lifecycle.rs +++ b/src/api/s3/lifecycle.rs @@ -91,35 +91,46 @@ pub struct LifecycleConfiguration { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct LifecycleRule { - #[serde(rename = "ID")] + #[serde(rename = "ID", skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(rename = "Status")] pub status: Value, - #[serde(rename = "Filter", default)] + #[serde(rename = "Filter", default, skip_serializing_if = "Option::is_none")] pub filter: Option, - #[serde(rename = "Expiration", default)] + #[serde( + rename = "Expiration", + default, + skip_serializing_if = "Option::is_none" + )] pub expiration: Option, - #[serde(rename = "AbortIncompleteMultipartUpload", default)] + #[serde( + rename = "AbortIncompleteMultipartUpload", + default, + skip_serializing_if = "Option::is_none" + )] pub abort_incomplete_mpu: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)] pub struct Filter { - #[serde(rename = "And")] + #[serde(rename = "And", skip_serializing_if = "Option::is_none")] pub and: Option>, - #[serde(rename = "Prefix")] + #[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")] pub prefix: Option, - #[serde(rename = "ObjectSizeGreaterThan")] + #[serde( + rename = "ObjectSizeGreaterThan", + skip_serializing_if = "Option::is_none" + )] pub size_gt: Option, - #[serde(rename = "ObjectSizeLessThan")] + #[serde(rename = "ObjectSizeLessThan", skip_serializing_if = "Option::is_none")] pub size_lt: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Expiration { - #[serde(rename = "Days")] + #[serde(rename = "Days", skip_serializing_if = "Option::is_none")] pub days: Option, - #[serde(rename = "Date")] + #[serde(rename = "Date", skip_serializing_if = "Option::is_none")] pub at_date: Option, } diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index c4863e7f..4c14f922 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -168,23 +168,29 @@ pub struct Target { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Condition { - #[serde(rename = "HttpErrorCodeReturnedEquals")] + #[serde( + rename = "HttpErrorCodeReturnedEquals", + skip_serializing_if = "Option::is_none" + )] pub http_error_code: Option, - #[serde(rename = "KeyPrefixEquals")] + #[serde(rename = "KeyPrefixEquals", skip_serializing_if = "Option::is_none")] pub prefix: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Redirect { - #[serde(rename = "HostName")] + #[serde(rename = "HostName", skip_serializing_if = "Option::is_none")] pub hostname: Option, - #[serde(rename = "Protocol")] + #[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")] pub protocol: Option, - #[serde(rename = "HttpRedirectCode")] + #[serde(rename = "HttpRedirectCode", skip_serializing_if = "Option::is_none")] pub http_redirect_code: Option, - #[serde(rename = "ReplaceKeyPrefixWith")] + #[serde( + rename = "ReplaceKeyPrefixWith", + skip_serializing_if = "Option::is_none" + )] pub replace_prefix: Option, - #[serde(rename = "ReplaceKeyWith")] + #[serde(rename = "ReplaceKeyWith", skip_serializing_if = "Option::is_none")] pub replace_full: Option, } diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index 2bb265d4..02113a7f 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -97,7 +97,7 @@ pub struct DeleteError { pub key: Option, #[serde(rename = "Message")] pub message: Value, - #[serde(rename = "VersionId")] + #[serde(rename = "VersionId", skip_serializing_if = "Option::is_none")] pub version_id: Option, } @@ -135,17 +135,17 @@ pub struct CompleteMultipartUploadResult { pub key: Value, #[serde(rename = "ETag")] pub etag: Value, - #[serde(rename = "ChecksumCRC32")] + #[serde(rename = "ChecksumCRC32", skip_serializing_if = "Option::is_none")] pub checksum_crc32: Option, - #[serde(rename = "ChecksumCRC32C")] + #[serde(rename = "ChecksumCRC32C", skip_serializing_if = "Option::is_none")] pub checksum_crc32c: Option, - #[serde(rename = "ChecksumCR64NVME")] + #[serde(rename = "ChecksumCR64NVME", skip_serializing_if = "Option::is_none")] pub checksum_crc64nvme: Option, - #[serde(rename = "ChecksumSHA1")] + #[serde(rename = "ChecksumSHA1", skip_serializing_if = "Option::is_none")] pub checksum_sha1: Option, - #[serde(rename = "ChecksumSHA256")] + #[serde(rename = "ChecksumSHA256", skip_serializing_if = "Option::is_none")] pub checksum_sha256: Option, - #[serde(rename = "ChecksumType")] + #[serde(rename = "ChecksumType", skip_serializing_if = "Option::is_none")] pub checksum_type: Option, } @@ -179,17 +179,17 @@ pub struct ListMultipartUploadsResult { pub xmlns: (), #[serde(rename = "Bucket")] pub bucket: Value, - #[serde(rename = "KeyMarker")] + #[serde(rename = "KeyMarker", skip_serializing_if = "Option::is_none")] pub key_marker: Option, - #[serde(rename = "UploadIdMarker")] + #[serde(rename = "UploadIdMarker", skip_serializing_if = "Option::is_none")] pub upload_id_marker: Option, - #[serde(rename = "NextKeyMarker")] + #[serde(rename = "NextKeyMarker", skip_serializing_if = "Option::is_none")] pub next_key_marker: Option, - #[serde(rename = "NextUploadIdMarker")] + #[serde(rename = "NextUploadIdMarker", skip_serializing_if = "Option::is_none")] pub next_upload_id_marker: Option, #[serde(rename = "Prefix")] pub prefix: Value, - #[serde(rename = "Delimiter")] + #[serde(rename = "Delimiter", skip_serializing_if = "Option::is_none")] pub delimiter: Option, #[serde(rename = "MaxUploads")] pub max_uploads: IntValue, @@ -199,7 +199,7 @@ pub struct ListMultipartUploadsResult { pub upload: Vec, #[serde(rename = "CommonPrefixes")] pub common_prefixes: Vec, - #[serde(rename = "EncodingType")] + #[serde(rename = "EncodingType", skip_serializing_if = "Option::is_none")] pub encoding_type: Option, } @@ -213,15 +213,15 @@ pub struct PartItem { pub part_number: IntValue, #[serde(rename = "Size")] pub size: IntValue, - #[serde(rename = "ChecksumCRC32")] + #[serde(rename = "ChecksumCRC32", skip_serializing_if = "Option::is_none")] pub checksum_crc32: Option, - #[serde(rename = "ChecksumCRC32C")] + #[serde(rename = "ChecksumCRC32C", skip_serializing_if = "Option::is_none")] pub checksum_crc32c: Option, - #[serde(rename = "ChecksumCRC64NVME")] + #[serde(rename = "ChecksumCRC64NVME", skip_serializing_if = "Option::is_none")] pub checksum_crc64nvme: Option, - #[serde(rename = "ChecksumSHA1")] + #[serde(rename = "ChecksumSHA1", skip_serializing_if = "Option::is_none")] pub checksum_sha1: Option, - #[serde(rename = "ChecksumSHA256")] + #[serde(rename = "ChecksumSHA256", skip_serializing_if = "Option::is_none")] pub checksum_sha256: Option, } @@ -235,9 +235,12 @@ pub struct ListPartsResult { pub key: Value, #[serde(rename = "UploadId")] pub upload_id: Value, - #[serde(rename = "PartNumberMarker")] + #[serde(rename = "PartNumberMarker", skip_serializing_if = "Option::is_none")] pub part_number_marker: Option, - #[serde(rename = "NextPartNumberMarker")] + #[serde( + rename = "NextPartNumberMarker", + skip_serializing_if = "Option::is_none" + )] pub next_part_number_marker: Option, #[serde(rename = "MaxParts")] pub max_parts: IntValue, @@ -281,23 +284,26 @@ pub struct ListBucketResult { pub name: Value, #[serde(rename = "Prefix")] pub prefix: Value, - #[serde(rename = "Marker")] + #[serde(rename = "Marker", skip_serializing_if = "Option::is_none")] pub marker: Option, - #[serde(rename = "NextMarker")] + #[serde(rename = "NextMarker", skip_serializing_if = "Option::is_none")] pub next_marker: Option, - #[serde(rename = "StartAfter")] + #[serde(rename = "StartAfter", skip_serializing_if = "Option::is_none")] pub start_after: Option, - #[serde(rename = "ContinuationToken")] + #[serde(rename = "ContinuationToken", skip_serializing_if = "Option::is_none")] pub continuation_token: Option, - #[serde(rename = "NextContinuationToken")] + #[serde( + rename = "NextContinuationToken", + skip_serializing_if = "Option::is_none" + )] pub next_continuation_token: Option, - #[serde(rename = "KeyCount")] + #[serde(rename = "KeyCount", skip_serializing_if = "Option::is_none")] pub key_count: Option, #[serde(rename = "MaxKeys")] pub max_keys: IntValue, - #[serde(rename = "Delimiter")] + #[serde(rename = "Delimiter", skip_serializing_if = "Option::is_none")] pub delimiter: Option, - #[serde(rename = "EncodingType")] + #[serde(rename = "EncodingType", skip_serializing_if = "Option::is_none")] pub encoding_type: Option, #[serde(rename = "IsTruncated")] pub is_truncated: Value, @@ -311,7 +317,7 @@ pub struct ListBucketResult { pub struct VersioningConfiguration { #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), - #[serde(rename = "Status")] + #[serde(rename = "Status", skip_serializing_if = "Option::is_none")] pub status: Option, } @@ -335,7 +341,7 @@ pub struct Grantee { pub xmlns_xsi: (), #[serde(rename = "@xsi:type")] pub typ: String, - #[serde(rename = "DisplayName")] + #[serde(rename = "DisplayName", skip_serializing_if = "Option::is_none")] pub display_name: Option, #[serde(rename = "ID")] pub id: Option, @@ -359,7 +365,7 @@ pub struct AccessControlList { pub struct AccessControlPolicy { #[serde(rename = "@xmlns", serialize_with = "xmlns_tag")] pub xmlns: (), - #[serde(rename = "Owner")] + #[serde(rename = "Owner", skip_serializing_if = "Option::is_none")] pub owner: Option, #[serde(rename = "AccessControlList")] pub acl: AccessControlList, From 6591044c2e6b7831833dec3be27003c633327e87 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Mon, 16 Feb 2026 12:33:04 +0100 Subject: [PATCH 08/10] fix: set quote level to full for xml serialization also remove use of intermediate String --- src/api/s3/xml.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index 02113a7f..b2c0bb87 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -1,11 +1,14 @@ -use quick_xml::se::to_string; +use quick_xml::se::{self, QuoteLevel}; use serde::{Deserialize, Serialize, Serializer}; use crate::error::Error as ApiError; pub fn to_xml_with_header(x: &T) -> Result { let mut xml = r#""#.to_string(); - xml.push_str(&to_string(x)?); + + let mut ser = se::Serializer::new(&mut xml); + ser.set_quote_level(QuoteLevel::Full); + let _serialized = x.serialize(ser)?; Ok(xml) } From 25766262402b7ed2a51d9e289d557a6a52b3dd44 Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Mon, 16 Feb 2026 12:40:34 +0100 Subject: [PATCH 09/10] fix: configure xmk serializer to expand empty elements --- src/api/s3/xml.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index b2c0bb87..a716da77 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -1,4 +1,4 @@ -use quick_xml::se::{self, QuoteLevel}; +use quick_xml::se::{self, EmptyElementHandling, QuoteLevel}; use serde::{Deserialize, Serialize, Serializer}; use crate::error::Error as ApiError; @@ -7,7 +7,8 @@ pub fn to_xml_with_header(x: &T) -> Result { let mut xml = r#""#.to_string(); let mut ser = se::Serializer::new(&mut xml); - ser.set_quote_level(QuoteLevel::Full); + ser.set_quote_level(QuoteLevel::Full) + .empty_element_handling(EmptyElementHandling::Expanded); let _serialized = x.serialize(ser)?; Ok(xml) } From 290a7f5ab6be07d69769d1822b44c6ad60a0a73c Mon Sep 17 00:00:00 2001 From: Gwen Lg Date: Mon, 16 Feb 2026 14:55:22 +0100 Subject: [PATCH 10/10] fix: VersioningConfiguration xml reference empty element handling is set as expanded and be consistant. --- src/api/s3/xml.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/s3/xml.rs b/src/api/s3/xml.rs index a716da77..952a46ee 100644 --- a/src/api/s3/xml.rs +++ b/src/api/s3/xml.rs @@ -468,7 +468,7 @@ mod tests { assert_eq!( to_xml_with_header(&get_bucket_versioning)?, "\ -" +" ); let get_bucket_versioning2 = VersioningConfiguration { xmlns: (),