1use 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
19pub struct S3Client<'a> {
21 ops: S3Ops<'a>,
22}
23
24impl<'a> S3Client<'a> {
25 pub(crate) fn new(client: &'a AwsHttpClient) -> Self {
27 Self {
28 ops: S3Ops::new(client),
29 }
30 }
31
32 pub async fn put_bucket_policy(&self, bucket: &str, body: &str) -> Result<()> {
34 self.ops.put_bucket_policy(bucket, body).await
35 }
36
37 pub async fn delete_bucket_policy(&self, bucket: &str) -> Result<()> {
39 self.ops.delete_bucket_policy(bucket).await
40 }
41
42 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 pub async fn list_buckets(&self) -> Result<ListBucketsResponse> {
55 self.ops.list_buckets().await
56 }
57
58 pub async fn get_bucket_versioning(&self, bucket: &str) -> Result<GetBucketVersioningResponse> {
60 self.ops.get_bucket_versioning(bucket).await
61 }
62
63 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 pub async fn get_bucket_logging(&self, bucket: &str) -> Result<GetBucketLoggingResponse> {
73 self.ops.get_bucket_logging(bucket).await
74 }
75
76 pub async fn get_bucket_acl(&self, bucket: &str) -> Result<GetBucketAclResponse> {
78 self.ops.get_bucket_acl(bucket).await
79 }
80
81 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 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 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 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 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 pub async fn put_bucket_logging(&self, bucket: &str, body: &BucketLoggingStatus) -> Result<()> {
147 self.ops.put_bucket_logging(bucket, body).await
148 }
149
150 pub async fn delete_bucket_lifecycle_configuration(&self, bucket: &str) -> Result<()> {
154 self.ops.delete_bucket_lifecycle_configuration(bucket).await
155 }
156
157 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 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
212fn 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('&', "&")
335 .replace('<', "<")
336 .replace('>', ">")
337 .replace('"', """)
338 .replace('\'', "'")
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}