Skip to main content

aws_lite_rs/api/
s3.rs

1//! Amazon S3 API client.
2//!
3//! Thin wrapper over generated ops. All URL construction and HTTP methods
4//! are in `ops::s3::S3Ops`. This layer adds:
5//! - Ergonomic method signatures
6//! - Custom XML serialization for lifecycle configuration (S3 expects
7//!   non-standard XML element names that `quick_xml::se` cannot produce)
8
9use crate::{
10    AwsHttpClient, Result, iam_policy::PolicyDocument, ops::s3::S3Ops,
11    types::s3::BucketLifecycleConfiguration, types::s3::BucketLoggingStatus,
12    types::s3::GetBucketAclResponse, types::s3::GetBucketLifecycleConfigurationResponse,
13    types::s3::GetBucketLoggingResponse, types::s3::GetBucketVersioningResponse,
14    types::s3::Grantee, types::s3::ListBucketsResponse, types::s3::PublicAccessBlockConfiguration,
15    types::s3::ServerSideEncryptionConfiguration, types::s3::VersioningConfiguration,
16};
17use urlencoding::encode;
18
19/// Client for the Amazon S3 API
20pub struct S3Client<'a> {
21    ops: S3Ops<'a>,
22}
23
24impl<'a> S3Client<'a> {
25    /// Create a new Amazon S3 API client
26    pub(crate) fn new(client: &'a AwsHttpClient) -> Self {
27        Self {
28            ops: S3Ops::new(client),
29        }
30    }
31
32    /// Applies an Amazon S3 bucket policy to an Amazon S3 bucket.
33    pub async fn put_bucket_policy(&self, bucket: &str, body: &str) -> Result<()> {
34        self.ops.put_bucket_policy(bucket, body).await
35    }
36
37    /// Deletes the policy of a specified bucket.
38    pub async fn delete_bucket_policy(&self, bucket: &str) -> Result<()> {
39        self.ops.delete_bucket_policy(bucket).await
40    }
41
42    /// Creates or modifies the PublicAccessBlock configuration for an Amazon S3 bucket.
43    pub async fn put_public_access_block(
44        &self,
45        bucket: &str,
46        body: &PublicAccessBlockConfiguration,
47    ) -> Result<()> {
48        self.ops.put_public_access_block(bucket, body).await
49    }
50
51    // ---- Read operations ----
52
53    /// Returns a list of all buckets owned by the authenticated sender of the request.
54    pub async fn list_buckets(&self) -> Result<ListBucketsResponse> {
55        self.ops.list_buckets().await
56    }
57
58    /// Returns the versioning state of a bucket.
59    pub async fn get_bucket_versioning(&self, bucket: &str) -> Result<GetBucketVersioningResponse> {
60        self.ops.get_bucket_versioning(bucket).await
61    }
62
63    /// Returns the default encryption configuration for an Amazon S3 bucket.
64    pub async fn get_bucket_encryption(
65        &self,
66        bucket: &str,
67    ) -> Result<ServerSideEncryptionConfiguration> {
68        self.ops.get_bucket_encryption(bucket).await
69    }
70
71    /// Returns the logging status of a bucket.
72    pub async fn get_bucket_logging(&self, bucket: &str) -> Result<GetBucketLoggingResponse> {
73        self.ops.get_bucket_logging(bucket).await
74    }
75
76    /// Returns the access control list (ACL) of a bucket.
77    pub async fn get_bucket_acl(&self, bucket: &str) -> Result<GetBucketAclResponse> {
78        self.ops.get_bucket_acl(bucket).await
79    }
80
81    /// Returns the lifecycle configuration information set on the bucket.
82    pub async fn get_bucket_lifecycle_configuration(
83        &self,
84        bucket: &str,
85    ) -> Result<GetBucketLifecycleConfigurationResponse> {
86        self.ops.get_bucket_lifecycle_configuration(bucket).await
87    }
88
89    /// Retrieves the PublicAccessBlock configuration for an Amazon S3 bucket.
90    pub async fn get_public_access_block(
91        &self,
92        bucket: &str,
93    ) -> Result<PublicAccessBlockConfiguration> {
94        self.ops.get_public_access_block(bucket).await
95    }
96
97    /// Returns the bucket policy as a parsed [`PolicyDocument`].
98    ///
99    /// This operation is implemented manually because S3 returns the policy as a raw JSON
100    /// payload (not XML). The JSON is deserialized into a typed `PolicyDocument` so callers
101    /// receive structured statements rather than a raw string to re-parse.
102    pub async fn get_bucket_policy(&self, bucket: &str) -> Result<PolicyDocument> {
103        let base = self.s3_base_url();
104        let url = format!("{}/{}?policy", base, encode(bucket));
105        let response = self.ops.client.get(&url, "s3").await?;
106        let response = response.error_for_status("xml").await?;
107        let bytes = response
108            .bytes()
109            .await
110            .map_err(|e| crate::AwsError::InvalidResponse {
111                message: format!("Failed to read get_bucket_policy response: {e}"),
112                body: None,
113            })?;
114        let json =
115            String::from_utf8(bytes.to_vec()).map_err(|e| crate::AwsError::InvalidResponse {
116                message: format!("Invalid UTF-8 in get_bucket_policy response: {e}"),
117                body: None,
118            })?;
119        PolicyDocument::from_json(&json).ok_or_else(|| crate::AwsError::InvalidResponse {
120            message: "Failed to parse bucket policy JSON as PolicyDocument".into(),
121            body: Some(json),
122        })
123    }
124
125    // ---- Write operations ----
126
127    /// Sets the versioning state of an existing bucket.
128    pub async fn put_bucket_versioning(
129        &self,
130        bucket: &str,
131        body: &VersioningConfiguration,
132    ) -> Result<()> {
133        self.ops.put_bucket_versioning(bucket, body).await
134    }
135
136    /// Creates the default encryption configuration for an Amazon S3 bucket.
137    pub async fn put_bucket_encryption(
138        &self,
139        bucket: &str,
140        body: &ServerSideEncryptionConfiguration,
141    ) -> Result<()> {
142        self.ops.put_bucket_encryption(bucket, body).await
143    }
144
145    /// Set the logging parameters for a bucket.
146    pub async fn put_bucket_logging(&self, bucket: &str, body: &BucketLoggingStatus) -> Result<()> {
147        self.ops.put_bucket_logging(bucket, body).await
148    }
149
150    /// Deletes the lifecycle configuration from the specified bucket.
151    ///
152    /// Returns 204 even if no lifecycle configuration exists (idempotent).
153    pub async fn delete_bucket_lifecycle_configuration(&self, bucket: &str) -> Result<()> {
154        self.ops.delete_bucket_lifecycle_configuration(bucket).await
155    }
156
157    /// Creates a new lifecycle configuration for the bucket or replaces an existing lifecycle
158    /// configuration.
159    ///
160    /// Uses hand-built XML because S3 expects `<LifecycleConfiguration>` root with
161    /// `<Rule>` elements (not `<BucketLifecycleConfiguration>` / `<Rules>`).
162    pub async fn put_bucket_lifecycle_configuration(
163        &self,
164        bucket: &str,
165        body: &BucketLifecycleConfiguration,
166    ) -> Result<()> {
167        let xml = build_lifecycle_xml(body);
168        let base = self.s3_base_url();
169        let url = format!("{}/{}?lifecycle", base, encode(bucket));
170        let response = self
171            .ops
172            .client
173            .put(&url, "s3", xml.as_bytes(), "application/xml")
174            .await?;
175        response.error_for_status("xml").await?;
176        Ok(())
177    }
178
179    fn s3_base_url(&self) -> String {
180        #[cfg(any(test, feature = "test-support"))]
181        {
182            if let Some(ref base) = self.ops.client.base_url {
183                return base.trim_end_matches('/').to_string();
184            }
185        }
186        format!("https://s3.{}.amazonaws.com", self.ops.client.region())
187    }
188}
189
190impl Grantee {
191    /// Infer the grantee type from the populated fields.
192    ///
193    /// S3 encodes the grantee type as an `xsi:type` XML attribute on `<Grantee>`, which
194    /// the codegen cannot deserialize (it is a namespace-prefixed attribute, not an element).
195    /// This method approximates it from the fields that *are* present:
196    /// - `URI` is set only for group grantees (e.g. `AllUsers`, `AuthenticatedUsers`)
197    /// - `ID` is set for canonical user grantees
198    ///
199    /// The URI value (e.g. `http://acs.amazonaws.com/groups/global/AllUsers`) is typically
200    /// what callers match on anyway; this method provides the type label as a convenience.
201    pub fn grantee_type(&self) -> &str {
202        if self.uri.is_some() {
203            "Group"
204        } else if self.id.is_some() {
205            "CanonicalUser"
206        } else {
207            "Unknown"
208        }
209    }
210}
211
212/// Build S3-compatible lifecycle configuration XML.
213///
214/// S3 expects specific element names that differ from the serde-derived names:
215/// - Root: `<LifecycleConfiguration>` (not `<BucketLifecycleConfiguration>`)
216/// - Each rule: `<Rule>` (not wrapped in `<Rules>`)
217/// - Rule ID: `<ID>` (not `<Id>`)
218fn build_lifecycle_xml(config: &BucketLifecycleConfiguration) -> String {
219    use std::fmt::Write;
220
221    let mut xml = String::new();
222    write!(
223        xml,
224        r#"<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">"#
225    )
226    .unwrap();
227
228    for rule in &config.rules {
229        xml.push_str("<Rule>");
230
231        if let Some(ref id) = rule.id {
232            write!(xml, "<ID>{}</ID>", escape_xml(id)).unwrap();
233        }
234        if let Some(ref prefix) = rule.prefix {
235            write!(xml, "<Prefix>{}</Prefix>", escape_xml(prefix)).unwrap();
236        }
237        if let Some(ref filter) = rule.filter {
238            xml.push_str("<Filter>");
239            if let Some(ref p) = filter.prefix {
240                write!(xml, "<Prefix>{}</Prefix>", escape_xml(p)).unwrap();
241            }
242            if let Some(v) = filter.object_size_greater_than {
243                write!(xml, "<ObjectSizeGreaterThan>{v}</ObjectSizeGreaterThan>").unwrap();
244            }
245            if let Some(v) = filter.object_size_less_than {
246                write!(xml, "<ObjectSizeLessThan>{v}</ObjectSizeLessThan>").unwrap();
247            }
248            xml.push_str("</Filter>");
249        }
250
251        write!(xml, "<Status>{}</Status>", escape_xml(&rule.status)).unwrap();
252
253        if let Some(ref exp) = rule.expiration {
254            xml.push_str("<Expiration>");
255            if let Some(ref date) = exp.date {
256                write!(xml, "<Date>{}</Date>", escape_xml(date)).unwrap();
257            }
258            if let Some(days) = exp.days {
259                write!(xml, "<Days>{days}</Days>").unwrap();
260            }
261            if let Some(marker) = exp.expired_object_delete_marker {
262                write!(
263                    xml,
264                    "<ExpiredObjectDeleteMarker>{marker}</ExpiredObjectDeleteMarker>"
265                )
266                .unwrap();
267            }
268            xml.push_str("</Expiration>");
269        }
270
271        for transition in &rule.transitions {
272            xml.push_str("<Transition>");
273            if let Some(ref date) = transition.date {
274                write!(xml, "<Date>{}</Date>", escape_xml(date)).unwrap();
275            }
276            if let Some(days) = transition.days {
277                write!(xml, "<Days>{days}</Days>").unwrap();
278            }
279            if let Some(ref sc) = transition.storage_class {
280                write!(xml, "<StorageClass>{}</StorageClass>", escape_xml(sc)).unwrap();
281            }
282            xml.push_str("</Transition>");
283        }
284
285        for nvt in &rule.noncurrent_version_transitions {
286            xml.push_str("<NoncurrentVersionTransition>");
287            if let Some(days) = nvt.noncurrent_days {
288                write!(xml, "<NoncurrentDays>{days}</NoncurrentDays>").unwrap();
289            }
290            if let Some(ref sc) = nvt.storage_class {
291                write!(xml, "<StorageClass>{}</StorageClass>", escape_xml(sc)).unwrap();
292            }
293            if let Some(n) = nvt.newer_noncurrent_versions {
294                write!(
295                    xml,
296                    "<NewerNoncurrentVersions>{n}</NewerNoncurrentVersions>"
297                )
298                .unwrap();
299            }
300            xml.push_str("</NoncurrentVersionTransition>");
301        }
302
303        if let Some(ref nve) = rule.noncurrent_version_expiration {
304            xml.push_str("<NoncurrentVersionExpiration>");
305            if let Some(days) = nve.noncurrent_days {
306                write!(xml, "<NoncurrentDays>{days}</NoncurrentDays>").unwrap();
307            }
308            if let Some(n) = nve.newer_noncurrent_versions {
309                write!(
310                    xml,
311                    "<NewerNoncurrentVersions>{n}</NewerNoncurrentVersions>"
312                )
313                .unwrap();
314            }
315            xml.push_str("</NoncurrentVersionExpiration>");
316        }
317
318        if let Some(ref abort) = rule.abort_incomplete_multipart_upload {
319            xml.push_str("<AbortIncompleteMultipartUpload>");
320            if let Some(days) = abort.days_after_initiation {
321                write!(xml, "<DaysAfterInitiation>{days}</DaysAfterInitiation>").unwrap();
322            }
323            xml.push_str("</AbortIncompleteMultipartUpload>");
324        }
325
326        xml.push_str("</Rule>");
327    }
328
329    xml.push_str("</LifecycleConfiguration>");
330    xml
331}
332
333fn escape_xml(s: &str) -> String {
334    s.replace('&', "&amp;")
335        .replace('<', "&lt;")
336        .replace('>', "&gt;")
337        .replace('"', "&quot;")
338        .replace('\'', "&apos;")
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::types::s3::*;
345
346    fn make_client_with_mock(mock: crate::MockClient) -> crate::AwsHttpClient {
347        crate::AwsHttpClient::from_mock(mock)
348    }
349
350    #[tokio::test]
351    async fn list_buckets_returns_parsed_response() {
352        let mut mock = crate::MockClient::new();
353        mock.expect_get("/")
354            .returning_bytes(
355                r#"<ListAllMyBucketsResult>
356                    <Buckets><Bucket><Name>my-bucket</Name><CreationDate>2026-01-01T00:00:00Z</CreationDate></Bucket></Buckets>
357                    <Owner><ID>owner-id</ID></Owner>
358                </ListAllMyBucketsResult>"#.as_bytes().to_vec(),
359            );
360        let client = make_client_with_mock(mock);
361        let result = client.s3().list_buckets().await.unwrap();
362        assert_eq!(result.buckets.len(), 1);
363        assert_eq!(result.buckets[0].name.as_deref(), Some("my-bucket"));
364        assert_eq!(
365            result.owner.as_ref().unwrap().id.as_deref(),
366            Some("owner-id")
367        );
368    }
369
370    #[tokio::test]
371    async fn get_bucket_versioning_returns_status() {
372        let mut mock = crate::MockClient::new();
373        mock.expect_get("/test-bucket?versioning").returning_bytes(
374            "<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"
375                .as_bytes()
376                .to_vec(),
377        );
378        let client = make_client_with_mock(mock);
379        let result = client
380            .s3()
381            .get_bucket_versioning("test-bucket")
382            .await
383            .unwrap();
384        assert_eq!(result.status.as_deref(), Some("Enabled"));
385    }
386
387    #[tokio::test]
388    async fn get_bucket_encryption_returns_rules() {
389        let mut mock = crate::MockClient::new();
390        mock.expect_get("/test-bucket?encryption")
391            .returning_bytes(
392                r#"<ServerSideEncryptionConfiguration>
393                    <Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault><BucketKeyEnabled>false</BucketKeyEnabled></Rule>
394                </ServerSideEncryptionConfiguration>"#.as_bytes().to_vec(),
395            );
396        let client = make_client_with_mock(mock);
397        let result = client
398            .s3()
399            .get_bucket_encryption("test-bucket")
400            .await
401            .unwrap();
402        assert_eq!(result.rules.len(), 1);
403        assert_eq!(
404            result.rules[0]
405                .apply_server_side_encryption_by_default
406                .as_ref()
407                .unwrap()
408                .sse_algorithm,
409            "AES256"
410        );
411    }
412
413    #[tokio::test]
414    async fn get_bucket_logging_returns_config() {
415        let mut mock = crate::MockClient::new();
416        mock.expect_get("/test-bucket?logging")
417            .returning_bytes(
418                r#"<BucketLoggingStatus>
419                    <LoggingEnabled><TargetBucket>log-bucket</TargetBucket><TargetPrefix>logs/</TargetPrefix></LoggingEnabled>
420                </BucketLoggingStatus>"#.as_bytes().to_vec(),
421            );
422        let client = make_client_with_mock(mock);
423        let result = client.s3().get_bucket_logging("test-bucket").await.unwrap();
424        let logging = result.logging_enabled.unwrap();
425        assert_eq!(logging.target_bucket, "log-bucket");
426        assert_eq!(logging.target_prefix, "logs/");
427    }
428
429    #[tokio::test]
430    async fn get_bucket_acl_returns_grants() {
431        let mut mock = crate::MockClient::new();
432        mock.expect_get("/test-bucket?acl")
433            .returning_bytes(
434                r#"<AccessControlPolicy>
435                    <Owner><ID>owner-id</ID></Owner>
436                    <AccessControlList><Grant><Grantee><ID>grantee-id</ID></Grantee><Permission>FULL_CONTROL</Permission></Grant></AccessControlList>
437                </AccessControlPolicy>"#.as_bytes().to_vec(),
438            );
439        let client = make_client_with_mock(mock);
440        let result = client.s3().get_bucket_acl("test-bucket").await.unwrap();
441        assert_eq!(
442            result.owner.as_ref().unwrap().id.as_deref(),
443            Some("owner-id")
444        );
445        assert_eq!(result.grants.len(), 1);
446        assert_eq!(result.grants[0].permission.as_deref(), Some("FULL_CONTROL"));
447    }
448
449    #[tokio::test]
450    async fn get_bucket_lifecycle_configuration_returns_rules() {
451        let mut mock = crate::MockClient::new();
452        mock.expect_get("/test-bucket?lifecycle").returning_bytes(
453            r#"<LifecycleConfiguration>
454                    <Rule><ID>test-rule</ID><Status>Enabled</Status></Rule>
455                </LifecycleConfiguration>"#
456                .as_bytes()
457                .to_vec(),
458        );
459        let client = make_client_with_mock(mock);
460        let result = client
461            .s3()
462            .get_bucket_lifecycle_configuration("test-bucket")
463            .await
464            .unwrap();
465        assert_eq!(result.rules.len(), 1);
466        assert_eq!(result.rules[0].id.as_deref(), Some("test-rule"));
467        assert_eq!(result.rules[0].status, "Enabled");
468    }
469
470    #[tokio::test]
471    async fn get_public_access_block_returns_config() {
472        let mut mock = crate::MockClient::new();
473        mock.expect_get("/test-bucket?publicAccessBlock")
474            .returning_bytes(
475                r#"<PublicAccessBlockConfiguration>
476                    <BlockPublicAcls>true</BlockPublicAcls>
477                    <IgnorePublicAcls>true</IgnorePublicAcls>
478                    <BlockPublicPolicy>false</BlockPublicPolicy>
479                    <RestrictPublicBuckets>false</RestrictPublicBuckets>
480                </PublicAccessBlockConfiguration>"#
481                    .as_bytes()
482                    .to_vec(),
483            );
484        let client = make_client_with_mock(mock);
485        let result = client
486            .s3()
487            .get_public_access_block("test-bucket")
488            .await
489            .unwrap();
490        assert_eq!(result.block_public_acls, Some(true));
491        assert_eq!(result.ignore_public_acls, Some(true));
492        assert_eq!(result.block_public_policy, Some(false));
493        assert_eq!(result.restrict_public_buckets, Some(false));
494    }
495
496    #[tokio::test]
497    async fn get_bucket_policy_returns_parsed_document() {
498        let mut mock = crate::MockClient::new();
499        let policy_json = r#"{"Version":"2012-10-17","Statement":[]}"#;
500        mock.expect_get("/test-bucket?policy")
501            .returning_bytes(policy_json.as_bytes().to_vec());
502        let client = make_client_with_mock(mock);
503        let result = client.s3().get_bucket_policy("test-bucket").await.unwrap();
504        assert_eq!(result.version.as_deref(), Some("2012-10-17"));
505        assert!(result.statement.is_empty());
506    }
507
508    #[tokio::test]
509    async fn get_bucket_policy_returns_parsed_statement() {
510        let mut mock = crate::MockClient::new();
511        let policy_json = r#"{
512            "Version": "2012-10-17",
513            "Statement": [{
514                "Effect": "Allow",
515                "Principal": "*",
516                "Action": "s3:GetObject",
517                "Resource": "arn:aws:s3:::test-bucket/*"
518            }]
519        }"#;
520        mock.expect_get("/test-bucket?policy")
521            .returning_bytes(policy_json.as_bytes().to_vec());
522        let client = make_client_with_mock(mock);
523        let result = client.s3().get_bucket_policy("test-bucket").await.unwrap();
524        assert_eq!(result.statement.len(), 1);
525        assert_eq!(result.statement[0].effect, crate::iam_policy::Effect::Allow);
526        assert_eq!(result.statement[0].action.as_slice(), vec!["s3:GetObject"]);
527    }
528
529    #[test]
530    fn grantee_type_infers_group_from_uri() {
531        let g = Grantee {
532            uri: Some("http://acs.amazonaws.com/groups/global/AllUsers".into()),
533            id: None,
534            display_name: None,
535        };
536        assert_eq!(g.grantee_type(), "Group");
537    }
538
539    #[test]
540    fn grantee_type_infers_canonical_user_from_id() {
541        let g = Grantee {
542            uri: None,
543            id: Some("abc123canonical".into()),
544            display_name: None,
545        };
546        assert_eq!(g.grantee_type(), "CanonicalUser");
547    }
548
549    #[test]
550    fn grantee_type_returns_unknown_when_no_uri_or_id() {
551        let g = Grantee {
552            uri: None,
553            id: None,
554            display_name: Some("display".into()),
555        };
556        assert_eq!(g.grantee_type(), "Unknown");
557    }
558
559    #[tokio::test]
560    async fn put_bucket_versioning_succeeds() {
561        let mut mock = crate::MockClient::new();
562        mock.expect_put("/test-bucket?versioning")
563            .returning_bytes(vec![]);
564        let client = make_client_with_mock(mock);
565        let config = VersioningConfiguration {
566            status: Some("Enabled".into()),
567            ..Default::default()
568        };
569        client
570            .s3()
571            .put_bucket_versioning("test-bucket", &config)
572            .await
573            .unwrap();
574    }
575
576    #[tokio::test]
577    async fn put_bucket_encryption_succeeds() {
578        let mut mock = crate::MockClient::new();
579        mock.expect_put("/test-bucket?encryption")
580            .returning_bytes(vec![]);
581        let client = make_client_with_mock(mock);
582        let config = ServerSideEncryptionConfiguration {
583            rules: vec![ServerSideEncryptionRule {
584                apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
585                    sse_algorithm: "AES256".into(),
586                    kms_master_key_id: None,
587                }),
588                bucket_key_enabled: Some(false),
589            }],
590        };
591        client
592            .s3()
593            .put_bucket_encryption("test-bucket", &config)
594            .await
595            .unwrap();
596    }
597
598    #[tokio::test]
599    async fn put_bucket_logging_succeeds() {
600        let mut mock = crate::MockClient::new();
601        mock.expect_put("/test-bucket?logging")
602            .returning_bytes(vec![]);
603        let client = make_client_with_mock(mock);
604        let logging = BucketLoggingStatus {
605            logging_enabled: Some(LoggingEnabled {
606                target_bucket: "log-bucket".into(),
607                target_prefix: "logs/".into(),
608            }),
609        };
610        client
611            .s3()
612            .put_bucket_logging("test-bucket", &logging)
613            .await
614            .unwrap();
615    }
616
617    #[tokio::test]
618    async fn delete_bucket_lifecycle_configuration_succeeds() {
619        let mut mock = crate::MockClient::new();
620        mock.expect_delete("/test-bucket?lifecycle")
621            .returning_bytes(vec![]);
622        let client = make_client_with_mock(mock);
623        client
624            .s3()
625            .delete_bucket_lifecycle_configuration("test-bucket")
626            .await
627            .unwrap();
628    }
629
630    #[test]
631    fn lifecycle_xml_matches_s3_format() {
632        let lifecycle = BucketLifecycleConfiguration {
633            rules: vec![LifecycleRule {
634                id: Some("test-rule".into()),
635                status: "Enabled".into(),
636                filter: Some(LifecycleRuleFilter {
637                    prefix: Some("logs/".into()),
638                    ..Default::default()
639                }),
640                expiration: Some(LifecycleExpiration {
641                    days: Some(30),
642                    ..Default::default()
643                }),
644                ..Default::default()
645            }],
646        };
647        let xml = build_lifecycle_xml(&lifecycle);
648
649        assert!(xml.starts_with(
650            r#"<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">"#
651        ));
652        assert!(xml.contains("<Rule>"));
653        assert!(xml.contains("<ID>test-rule</ID>"));
654        assert!(xml.contains("<Filter><Prefix>logs/</Prefix></Filter>"));
655        assert!(xml.contains("<Status>Enabled</Status>"));
656        assert!(xml.contains("<Expiration><Days>30</Days></Expiration>"));
657        assert!(xml.ends_with("</LifecycleConfiguration>"));
658    }
659}