1use aws_sdk_s3::{error::SdkError, Client};
34use serde::{Deserialize, Serialize};
35
36use crate::{error::AppError, ids::ProfileId};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct BucketSummary {
49 pub name: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub creation_date: Option<i64>,
54 #[serde(skip_serializing_if = "Option::is_none")]
57 pub region: Option<String>,
58 pub profile_id: ProfileId,
60}
61
62pub async fn list_buckets(
77 client: &Client,
78 profile_id: &ProfileId,
79) -> Result<Vec<BucketSummary>, AppError> {
80 let response = client.list_buckets().send().await.map_err(|e| {
81 if let SdkError::ServiceError(ref svc) = e {
83 let code = svc.err().meta().code().unwrap_or("");
84 if code == "AccessDenied" || code == "InvalidClientTokenId" {
85 return AppError::AccessDenied {
86 op: "ListBuckets".to_string(),
87 resource: "*".to_string(),
88 };
89 }
90 }
91 AppError::Network {
92 source: e.to_string(),
93 }
94 })?;
95
96 let buckets = response
97 .buckets()
98 .iter()
99 .map(|b| {
100 let name = b.name().unwrap_or("").to_string();
101 let creation_date = b
102 .creation_date()
103 .map(|d| d.secs() * 1000 + i64::from(d.subsec_nanos()) / 1_000_000);
104 BucketSummary {
105 name,
106 creation_date,
107 region: None,
108 profile_id: profile_id.clone(),
109 }
110 })
111 .collect();
112
113 Ok(buckets)
114}
115
116fn normalise_region(raw: Option<&str>) -> String {
128 match raw {
129 None | Some("") => "us-east-1".to_string(),
130 Some("EU") => "eu-west-1".to_string(),
131 Some(other) => other.to_string(),
132 }
133}
134
135pub async fn discover_bucket_region(
144 client: &Client,
145 bucket: &str,
146) -> Result<Option<String>, AppError> {
147 let result = client.get_bucket_location().bucket(bucket).send().await;
148
149 match result {
150 Ok(resp) => {
151 let constraint = resp.location_constraint().map(|lc| lc.as_str());
152 Ok(Some(normalise_region(constraint)))
153 }
154 Err(SdkError::ServiceError(ref svc_err)) => {
155 let code = svc_err.err().meta().code().unwrap_or("");
156 if code == "AccessDenied" || code == "NoSuchBucket" {
158 Ok(None)
159 } else {
160 Err(AppError::Network {
161 source: format!("GetBucketLocation({bucket}): {}", svc_err.err()),
162 })
163 }
164 }
165 Err(e) => Err(AppError::Network {
166 source: format!("GetBucketLocation({bucket}): {e}"),
167 }),
168 }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct ObjectEntry {
185 pub key: String,
187 pub size: u64,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub last_modified: Option<i64>,
192 #[serde(skip_serializing_if = "Option::is_none")]
195 pub etag: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub storage_class: Option<String>,
199 pub is_prefix: bool,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct ListPage {
218 pub entries: Vec<ObjectEntry>,
221 pub common_prefixes: Vec<String>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub next_continuation_token: Option<String>,
227 pub is_truncated: bool,
229 pub prefix: String,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub delimiter: Option<String>,
234}
235
236pub async fn list_objects(
254 client: &Client,
255 bucket: &str,
256 prefix: &str,
257 delimiter: Option<&str>,
258 continuation_token: Option<&str>,
259 max_keys: Option<i32>,
260) -> Result<ListPage, AppError> {
261 let effective_delimiter = delimiter.unwrap_or("/");
262
263 let mut req = client
264 .list_objects_v2()
265 .bucket(bucket)
266 .prefix(prefix)
267 .delimiter(effective_delimiter);
268
269 if let Some(token) = continuation_token {
270 req = req.continuation_token(token);
271 }
272 if let Some(n) = max_keys {
273 req = req.max_keys(n);
274 }
275
276 let resp = req.send().await.map_err(classify_sdk_error)?;
277
278 let mut entries: Vec<ObjectEntry> = Vec::new();
279 let mut common_prefixes: Vec<String> = Vec::new();
280
281 for obj in resp.contents() {
283 let key = obj.key().unwrap_or("").to_string();
284 let size = obj.size().unwrap_or(0) as u64;
285 let last_modified = obj
286 .last_modified()
287 .map(|dt| dt.secs() * 1000 + i64::from(dt.subsec_nanos()) / 1_000_000);
288 let etag = obj.e_tag().map(|s| s.trim_matches('"').to_string());
289 let storage_class = obj.storage_class().map(|sc| sc.as_str().to_string());
290
291 entries.push(ObjectEntry {
292 key,
293 size,
294 last_modified,
295 etag,
296 storage_class,
297 is_prefix: false,
298 });
299 }
300
301 for cp in resp.common_prefixes() {
303 let prefix_str = cp.prefix().unwrap_or("").to_string();
304 common_prefixes.push(prefix_str.clone());
305 entries.push(ObjectEntry {
306 key: prefix_str,
307 size: 0,
308 last_modified: None,
309 etag: None,
310 storage_class: None,
311 is_prefix: true,
312 });
313 }
314
315 Ok(ListPage {
316 entries,
317 common_prefixes,
318 next_continuation_token: resp.next_continuation_token().map(|s| s.to_string()),
319 is_truncated: resp.is_truncated().unwrap_or(false),
320 prefix: prefix.to_string(),
321 delimiter: Some(effective_delimiter.to_string()),
322 })
323}
324
325pub async fn list_objects_flat(
336 client: &Client,
337 bucket: &str,
338 prefix: &str,
339 continuation_token: Option<&str>,
340 max_keys: Option<i32>,
341) -> Result<ListPage, AppError> {
342 let mut req = client.list_objects_v2().bucket(bucket).prefix(prefix);
343
344 if let Some(token) = continuation_token {
345 req = req.continuation_token(token);
346 }
347 if let Some(n) = max_keys {
348 req = req.max_keys(n);
349 }
350
351 let resp = req.send().await.map_err(classify_sdk_error)?;
352
353 let entries: Vec<ObjectEntry> = resp
354 .contents()
355 .iter()
356 .map(|obj| {
357 let key = obj.key().unwrap_or("").to_string();
358 let size = obj.size().unwrap_or(0) as u64;
359 let last_modified = obj
360 .last_modified()
361 .map(|dt| dt.secs() * 1000 + i64::from(dt.subsec_nanos()) / 1_000_000);
362 let etag = obj.e_tag().map(|s| s.trim_matches('"').to_string());
363 let storage_class = obj.storage_class().map(|sc| sc.as_str().to_string());
364
365 ObjectEntry {
366 key,
367 size,
368 last_modified,
369 etag,
370 storage_class,
371 is_prefix: false,
372 }
373 })
374 .collect();
375
376 Ok(ListPage {
377 entries,
378 common_prefixes: Vec::new(),
379 next_continuation_token: resp.next_continuation_token().map(|s| s.to_string()),
380 is_truncated: resp.is_truncated().unwrap_or(false),
381 prefix: prefix.to_string(),
382 delimiter: None,
383 })
384}
385
386pub async fn list_objects_parallel_pages(
405 client: &Client,
406 bucket: &str,
407 prefix: &str,
408 max_pages: usize,
409) -> Result<Vec<ListPage>, AppError> {
410 let limit = if max_pages == 0 { 1 } else { max_pages };
411 let mut pages: Vec<ListPage> = Vec::with_capacity(limit);
412 let mut token: Option<String> = None;
413
414 for _ in 0..limit {
415 let page = list_objects(client, bucket, prefix, Some("/"), token.as_deref(), None).await?;
416
417 let truncated = page.is_truncated;
418 token = page.next_continuation_token.clone();
419 pages.push(page);
420
421 if !truncated || token.is_none() {
422 break;
423 }
424 }
425
426 Ok(pages)
427}
428
429fn classify_sdk_error(
435 e: SdkError<aws_sdk_s3::operation::list_objects_v2::ListObjectsV2Error>,
436) -> AppError {
437 if let SdkError::ServiceError(ref svc) = e {
438 let code = svc.err().meta().code().unwrap_or("");
439 match code {
440 "AccessDenied" | "InvalidClientTokenId" => {
441 return AppError::AccessDenied {
442 op: "ListObjectsV2".to_string(),
443 resource: "bucket".to_string(),
444 };
445 }
446 "NoSuchBucket" => {
447 return AppError::NotFound {
448 resource: "bucket".to_string(),
449 };
450 }
451 _ => {}
452 }
453 }
454 AppError::Network {
455 source: e.to_string(),
456 }
457}
458
459#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
470 fn object_entry_object_serialises_to_camel_case() {
471 let entry = ObjectEntry {
472 key: "photos/2024/img.jpg".to_string(),
473 size: 4096,
474 last_modified: Some(1_700_000_000_000),
475 etag: Some("abc123".to_string()),
476 storage_class: Some("STANDARD".to_string()),
477 is_prefix: false,
478 };
479 let v = serde_json::to_value(&entry).unwrap();
480 assert_eq!(v["key"], "photos/2024/img.jpg");
481 assert_eq!(v["size"], 4096_u64);
482 assert_eq!(v["lastModified"], 1_700_000_000_000_i64);
483 assert_eq!(v["etag"], "abc123");
484 assert_eq!(v["storageClass"], "STANDARD");
485 assert_eq!(v["isPrefix"], false);
486 }
487
488 #[test]
489 fn object_entry_prefix_skips_optional_fields() {
490 let entry = ObjectEntry {
491 key: "photos/".to_string(),
492 size: 0,
493 last_modified: None,
494 etag: None,
495 storage_class: None,
496 is_prefix: true,
497 };
498 let v = serde_json::to_value(&entry).unwrap();
499 assert_eq!(v["isPrefix"], true);
500 assert_eq!(v["size"], 0_u64);
501 assert!(!v.as_object().unwrap().contains_key("lastModified"));
502 assert!(!v.as_object().unwrap().contains_key("etag"));
503 assert!(!v.as_object().unwrap().contains_key("storageClass"));
504 }
505
506 #[test]
509 fn list_page_serialises_unified_entries() {
510 let page = ListPage {
511 entries: vec![
512 ObjectEntry {
513 key: "dir/".to_string(),
514 size: 0,
515 last_modified: None,
516 etag: None,
517 storage_class: None,
518 is_prefix: true,
519 },
520 ObjectEntry {
521 key: "file.txt".to_string(),
522 size: 512,
523 last_modified: Some(1_000),
524 etag: Some("etag1".to_string()),
525 storage_class: Some("STANDARD".to_string()),
526 is_prefix: false,
527 },
528 ],
529 common_prefixes: vec!["dir/".to_string()],
530 next_continuation_token: Some("token123".to_string()),
531 is_truncated: true,
532 prefix: String::new(),
533 delimiter: Some("/".to_string()),
534 };
535 let v = serde_json::to_value(&page).unwrap();
536 assert_eq!(v["entries"].as_array().unwrap().len(), 2);
537 assert_eq!(v["commonPrefixes"][0], "dir/");
538 assert_eq!(v["nextContinuationToken"], "token123");
539 assert_eq!(v["isTruncated"], true);
540 assert_eq!(v["delimiter"], "/");
541 }
542
543 #[test]
544 fn list_page_no_token_skips_next_continuation_token() {
545 let page = ListPage {
546 entries: vec![],
547 common_prefixes: vec![],
548 next_continuation_token: None,
549 is_truncated: false,
550 prefix: "some/".to_string(),
551 delimiter: None,
552 };
553 let v = serde_json::to_value(&page).unwrap();
554 assert!(!v.as_object().unwrap().contains_key("nextContinuationToken"));
555 assert!(!v.as_object().unwrap().contains_key("delimiter"));
556 assert_eq!(v["isTruncated"], false);
557 }
558
559 #[test]
568 fn synthetic_list_page_objects_and_prefixes_are_unified() {
569 let mut entries: Vec<ObjectEntry> = vec![
572 ObjectEntry {
573 key: "test/file1.txt".to_string(),
574 size: 100,
575 last_modified: Some(1_700_000_000_000),
576 etag: Some("abc".to_string()),
577 storage_class: Some("STANDARD".to_string()),
578 is_prefix: false,
579 },
580 ObjectEntry {
581 key: "test/file2.csv".to_string(),
582 size: 200,
583 last_modified: Some(1_700_000_001_000),
584 etag: Some("def".to_string()),
585 storage_class: Some("STANDARD".to_string()),
586 is_prefix: false,
587 },
588 ];
589 let common_prefix = "test/subdir/".to_string();
590 entries.push(ObjectEntry {
591 key: common_prefix.clone(),
592 size: 0,
593 last_modified: None,
594 etag: None,
595 storage_class: None,
596 is_prefix: true,
597 });
598
599 let page = ListPage {
600 entries,
601 common_prefixes: vec![common_prefix],
602 next_continuation_token: None,
603 is_truncated: false,
604 prefix: "test/".to_string(),
605 delimiter: Some("/".to_string()),
606 };
607
608 assert_eq!(page.entries.len(), 3);
609 assert!(page.entries.iter().filter(|e| e.is_prefix).count() == 1);
610 assert!(page.entries.iter().filter(|e| !e.is_prefix).count() == 2);
611 assert_eq!(page.common_prefixes.len(), 1);
612 assert!(!page.is_truncated);
613 }
614
615 #[test]
616 fn flat_page_has_no_common_prefixes() {
617 let entries: Vec<ObjectEntry> = (0..5_u32)
619 .map(|i| ObjectEntry {
620 key: format!("test/{:04}.txt", i),
621 size: u64::from(i) * 10,
622 last_modified: None,
623 etag: None,
624 storage_class: None,
625 is_prefix: false,
626 })
627 .collect();
628
629 let page = ListPage {
630 entries,
631 common_prefixes: Vec::new(),
632 next_continuation_token: None,
633 is_truncated: false,
634 prefix: "test/".to_string(),
635 delimiter: None,
636 };
637
638 assert!(page.common_prefixes.is_empty());
639 assert!(page.entries.iter().all(|e| !e.is_prefix));
640 assert_eq!(page.entries.len(), 5);
641 }
642
643 #[test]
646 fn unvalidated_profile_gate_returns_auth_error() {
647 let validated_at: Option<i64> = None;
648 let result: Result<(), crate::error::AppError> = if validated_at.is_none() {
649 Err(crate::error::AppError::Auth {
650 reason: "profile_not_validated_in_session".to_string(),
651 })
652 } else {
653 Ok(())
654 };
655 match result {
656 Err(crate::error::AppError::Auth { reason }) => {
657 assert_eq!(reason, "profile_not_validated_in_session");
658 }
659 _ => panic!("expected Auth error"),
660 }
661 }
662
663 #[test]
666 fn flat_cache_key_differs_from_hierarchical_cache_key() {
667 use crate::{cache::CacheKey, ids::BucketId};
668
669 let pid = ProfileId::new("p1");
670 let bid = BucketId::new("bucket-a");
671
672 let hierarchical = CacheKey::Objects {
673 profile: pid.clone(),
674 bucket: bid.clone(),
675 prefix: "photos/".to_string(),
676 };
677 let flat = CacheKey::Objects {
678 profile: pid.clone(),
679 bucket: bid.clone(),
680 prefix: "photos/__FLAT__".to_string(),
681 };
682
683 assert_ne!(
684 hierarchical.serialize_key(),
685 flat.serialize_key(),
686 "flat cache key must differ from hierarchical cache key"
687 );
688 }
689
690 #[test]
693 fn normalise_region_none_is_us_east_1() {
694 assert_eq!(normalise_region(None), "us-east-1");
695 }
696
697 #[test]
698 fn normalise_region_empty_is_us_east_1() {
699 assert_eq!(normalise_region(Some("")), "us-east-1");
700 }
701
702 #[test]
703 fn normalise_region_eu_alias_maps_to_eu_west_1() {
704 assert_eq!(normalise_region(Some("EU")), "eu-west-1");
705 }
706
707 #[test]
708 fn normalise_region_standard_values_pass_through() {
709 assert_eq!(normalise_region(Some("us-west-2")), "us-west-2");
710 assert_eq!(normalise_region(Some("ap-southeast-1")), "ap-southeast-1");
711 assert_eq!(normalise_region(Some("eu-central-1")), "eu-central-1");
712 }
713
714 #[test]
717 fn bucket_summary_serialises_to_camel_case() {
718 let summary = BucketSummary {
719 name: "my-bucket".to_string(),
720 creation_date: Some(1_700_000_000_000),
721 region: Some("us-east-1".to_string()),
722 profile_id: ProfileId::new("p1"),
723 };
724 let v = serde_json::to_value(&summary).unwrap();
725 assert_eq!(v["name"], "my-bucket");
726 assert_eq!(v["creationDate"], 1_700_000_000_000_i64);
727 assert_eq!(v["region"], "us-east-1");
728 assert_eq!(v["profileId"], "p1");
729 }
730
731 #[test]
732 fn bucket_summary_skips_none_optional_fields() {
733 let summary = BucketSummary {
734 name: "empty-bucket".to_string(),
735 creation_date: None,
736 region: None,
737 profile_id: ProfileId::new("p2"),
738 };
739 let v = serde_json::to_value(&summary).unwrap();
740 assert!(!v.as_object().unwrap().contains_key("creationDate"));
741 assert!(!v.as_object().unwrap().contains_key("region"));
742 }
743
744 #[test]
753 fn bucket_summary_round_trip_via_serde() {
754 let original = BucketSummary {
755 name: "round-trip-bucket".to_string(),
756 creation_date: Some(1_000_000_000),
757 region: Some("eu-west-1".to_string()),
758 profile_id: ProfileId::new("profile-abc"),
759 };
760 let json = serde_json::to_string(&original).unwrap();
761 let restored: BucketSummary = serde_json::from_str(&json).unwrap();
762 assert_eq!(restored.name, original.name);
763 assert_eq!(restored.creation_date, original.creation_date);
764 assert_eq!(restored.region, original.region);
765 assert_eq!(restored.profile_id, original.profile_id);
766 }
767}