1use std::collections::HashMap;
4use std::fmt;
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::Duration;
9
10use rskit_errors::{AppError, AppResult, ErrorCode};
11use rskit_storage::FileSource;
12use rskit_storage::store::{
13 FileStore, StorageConfig, StorageFactory, StorageRegistry, StoredFile, UploadOptions,
14 content_type_or_default, prefixed_key,
15};
16use rskit_util::env;
17use serde::{Deserialize, Serialize};
18use tokio::sync::OnceCell;
19
20#[derive(Clone, Deserialize, Serialize)]
25pub struct Config {
26 pub bucket: String,
28 pub region: Option<String>,
31 pub endpoint: Option<String>,
33 pub prefix: Option<String>,
35 #[serde(default)]
38 pub force_path_style: bool,
39 pub access_key_id: Option<String>,
41 pub secret_access_key: Option<String>,
43}
44
45impl fmt::Debug for Config {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.debug_struct("Config")
48 .field("bucket", &self.bucket)
49 .field("region", &self.region)
50 .field("endpoint", &self.endpoint)
51 .field("prefix", &self.prefix)
52 .field("force_path_style", &self.force_path_style)
53 .field(
54 "access_key_id",
55 &self.access_key_id.as_ref().map(|_| "<redacted>"),
56 )
57 .field(
58 "secret_access_key",
59 &self.secret_access_key.as_ref().map(|_| "<redacted>"),
60 )
61 .finish()
62 }
63}
64
65struct S3Store {
70 client: OnceCell<aws_sdk_s3::Client>,
71 builder: S3ClientBuilder,
72 config: Config,
73}
74
75type S3ClientFuture = Pin<Box<dyn Future<Output = AppResult<aws_sdk_s3::Client>> + Send>>;
76type S3ClientBuilder = Box<dyn Fn() -> S3ClientFuture + Send + Sync>;
77
78impl S3Store {
79 fn new(config: Config) -> AppResult<Self> {
84 let client_config = client_config_builder(&config)?.build();
85 let builder = Box::new(move || {
86 let client_config = client_config.clone();
87 Box::pin(async move { Ok(aws_sdk_s3::Client::from_conf(client_config)) })
88 as S3ClientFuture
89 });
90 Ok(Self {
91 client: OnceCell::new(),
92 builder,
93 config,
94 })
95 }
96
97 fn full_key(&self, key: &str) -> String {
98 prefixed_key(self.config.prefix.as_deref(), key)
99 }
100
101 async fn client(&self) -> AppResult<&aws_sdk_s3::Client> {
102 self.client.get_or_try_init(|| (self.builder)()).await
103 }
104}
105
106fn client_config_builder(config: &Config) -> AppResult<aws_sdk_s3::config::Builder> {
111 let (access_key, secret_key) = resolve_credentials(config)?;
112
113 let creds = aws_sdk_s3::config::Credentials::new(
114 &access_key,
115 &secret_key,
116 None,
117 None,
118 "rskit-storage-s3",
119 );
120
121 let mut builder = aws_sdk_s3::Config::builder()
122 .credentials_provider(creds)
123 .behavior_version_latest();
124
125 if let Some(region) = &config.region {
127 builder = builder.region(aws_sdk_s3::config::Region::new(region.clone()));
128 } else if let Some(region) = env::get_non_empty("AWS_REGION") {
129 builder = builder.region(aws_sdk_s3::config::Region::new(region));
130 } else if let Some(region) = env::get_non_empty("AWS_DEFAULT_REGION") {
131 builder = builder.region(aws_sdk_s3::config::Region::new(region));
132 }
133
134 if let Some(endpoint) = &config.endpoint {
135 builder = builder.endpoint_url(endpoint);
136 }
137
138 if config.force_path_style {
139 builder = builder.force_path_style(true);
140 }
141
142 Ok(builder)
143}
144
145#[async_trait::async_trait]
146impl FileStore for S3Store {
147 async fn upload(
148 &self,
149 source: &FileSource,
150 key: &str,
151 options: UploadOptions,
152 ) -> AppResult<StoredFile> {
153 let data = source.read_all().await?;
154 let size = data.len() as u64;
155 let full_key = self.full_key(key);
156 let client = self.client().await?;
157
158 let mut req = client
159 .put_object()
160 .bucket(&self.config.bucket)
161 .key(&full_key)
162 .body(data.to_vec().into());
163
164 req = req.content_type(content_type_or_default(options.content_type()));
165 for (k, v) in &options.metadata {
166 req = req.metadata(k, v);
167 }
168
169 req.send()
170 .await
171 .map_err(|e| AppError::new(ErrorCode::Internal, format!("S3 upload failed: {e}")))?;
172
173 let content_type = options.content_type().map(str::to_owned);
174 Ok(uploaded_file(
175 key,
176 size,
177 content_type.as_deref(),
178 options.metadata,
179 ))
180 }
181
182 async fn download(&self, key: &str) -> AppResult<FileSource> {
183 let full_key = self.full_key(key);
184 let client = self.client().await?;
185 let resp = client
186 .get_object()
187 .bucket(&self.config.bucket)
188 .key(&full_key)
189 .send()
190 .await
191 .map_err(|e| AppError::new(ErrorCode::NotFound, format!("S3 download failed: {e}")))?;
192
193 let data =
194 resp.body.collect().await.map_err(|e| {
195 AppError::new(ErrorCode::Internal, format!("S3 read body failed: {e}"))
196 })?;
197
198 Ok(FileSource::Bytes(bytes::Bytes::from(data.to_vec())))
199 }
200
201 async fn delete(&self, key: &str) -> AppResult<()> {
202 let full_key = self.full_key(key);
203 let client = self.client().await?;
204 client
205 .delete_object()
206 .bucket(&self.config.bucket)
207 .key(&full_key)
208 .send()
209 .await
210 .map_err(|e| AppError::new(ErrorCode::Internal, format!("S3 delete failed: {e}")))?;
211 Ok(())
212 }
213
214 async fn exists(&self, key: &str) -> AppResult<bool> {
215 let full_key = self.full_key(key);
216 let client = self.client().await?;
217 match client
218 .head_object()
219 .bucket(&self.config.bucket)
220 .key(&full_key)
221 .send()
222 .await
223 {
224 Ok(_) => Ok(true),
225 Err(_) => Ok(false),
226 }
227 }
228
229 async fn head(&self, key: &str) -> AppResult<StoredFile> {
230 let full_key = self.full_key(key);
231 let client = self.client().await?;
232 let resp = client
233 .head_object()
234 .bucket(&self.config.bucket)
235 .key(&full_key)
236 .send()
237 .await
238 .map_err(|e| AppError::new(ErrorCode::NotFound, format!("S3 head failed: {e}")))?;
239
240 Ok(stored_file_from_head(key, &resp))
241 }
242
243 async fn list(&self, prefix: &str, limit: Option<usize>) -> AppResult<Vec<StoredFile>> {
244 let full_prefix = self.full_key(prefix);
245 let client = self.client().await?;
246 let mut req = client
247 .list_objects_v2()
248 .bucket(&self.config.bucket)
249 .prefix(&full_prefix);
250
251 if let Some(max) = limit {
252 req = req.max_keys(max as i32);
253 }
254
255 let resp = req
256 .send()
257 .await
258 .map_err(|e| AppError::new(ErrorCode::Internal, format!("S3 list failed: {e}")))?;
259
260 let items = resp
261 .contents()
262 .iter()
263 .map(stored_file_from_object)
264 .collect();
265
266 Ok(items)
267 }
268
269 async fn presigned_url(&self, key: &str, expires_in: Duration) -> AppResult<String> {
270 let full_key = self.full_key(key);
271 let presigning_config = aws_sdk_s3::presigning::PresigningConfig::expires_in(expires_in)
272 .map_err(|e| {
273 AppError::new(
274 ErrorCode::InvalidInput,
275 format!("Invalid presigning duration: {e}"),
276 )
277 })?;
278
279 let presigned = self
280 .client()
281 .await?
282 .get_object()
283 .bucket(&self.config.bucket)
284 .key(&full_key)
285 .presigned(presigning_config)
286 .await
287 .map_err(|e| {
288 AppError::new(
289 ErrorCode::Internal,
290 format!("S3 presigned URL generation failed: {e}"),
291 )
292 })?;
293
294 Ok(presigned.uri().to_string())
295 }
296
297 async fn copy(&self, from_key: &str, to_key: &str) -> AppResult<StoredFile> {
298 let full_from = self.full_key(from_key);
299 let full_to = self.full_key(to_key);
300 let client = self.client().await?;
301
302 client
303 .copy_object()
304 .bucket(&self.config.bucket)
305 .copy_source(format!("{}/{}", self.config.bucket, full_from))
306 .key(&full_to)
307 .send()
308 .await
309 .map_err(|e| AppError::new(ErrorCode::Internal, format!("S3 copy failed: {e}")))?;
310
311 self.head(to_key).await
312 }
313
314 async fn rename(&self, from_key: &str, to_key: &str) -> AppResult<StoredFile> {
315 let result = self.copy(from_key, to_key).await?;
316 self.delete(from_key).await?;
317 Ok(result)
318 }
319}
320
321fn uploaded_file(
322 key: &str,
323 size: u64,
324 content_type: Option<&str>,
325 metadata: HashMap<String, String>,
326) -> StoredFile {
327 StoredFile::new(prefixed_key(None, key), size, content_type).with_metadata(metadata)
328}
329
330fn stored_file_from_head(
331 key: &str,
332 resp: &aws_sdk_s3::operation::head_object::HeadObjectOutput,
333) -> StoredFile {
334 StoredFile::new(
335 prefixed_key(None, key),
336 resp.content_length().unwrap_or(0) as u64,
337 resp.content_type(),
338 )
339 .with_metadata(
340 resp.metadata()
341 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
342 .unwrap_or_default(),
343 )
344}
345
346fn stored_file_from_object(obj: &aws_sdk_s3::types::Object) -> StoredFile {
347 StoredFile::new(
348 obj.key().unwrap_or(""),
349 obj.size().unwrap_or(0) as u64,
350 None,
351 )
352}
353
354fn resolve_credentials(config: &Config) -> AppResult<(String, String)> {
356 resolve_credentials_with(config, env::get_non_empty)
357}
358
359fn resolve_credentials_with(
360 config: &Config,
361 get_env: impl Fn(&str) -> Option<String>,
362) -> AppResult<(String, String)> {
363 if let (Some(key), Some(secret)) = (&config.access_key_id, &config.secret_access_key)
364 && !key.is_empty()
365 && !secret.is_empty()
366 {
367 return Ok((key.clone(), secret.clone()));
368 }
369
370 let key = get_env("AWS_ACCESS_KEY_ID");
371 let secret = get_env("AWS_SECRET_ACCESS_KEY");
372
373 let (Some(key), Some(secret)) = (key, secret) else {
374 return Err(AppError::new(
375 ErrorCode::MissingField,
376 "S3 credentials not found. Set access_key_id/secret_access_key in config \
377 or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment variables.",
378 ));
379 };
380
381 Ok((key, secret))
382}
383
384struct S3Factory {
385 config: Config,
386}
387
388#[async_trait::async_trait]
389impl StorageFactory for S3Factory {
390 async fn create(&self, _config: &StorageConfig) -> AppResult<Arc<dyn FileStore>> {
391 Ok(Arc::new(S3Store::new(self.config.clone())?))
392 }
393}
394
395pub fn register(registry: &mut StorageRegistry, config: Config) -> AppResult<()> {
397 registry.register("s3", Arc::new(S3Factory { config }))
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn config_deserializes_with_defaults() {
406 let json = r#"{"bucket": "test", "endpoint": "http://localhost:9000"}"#;
407 let cfg: Config = serde_json::from_str(json).unwrap();
408 assert_eq!(cfg.bucket, "test");
409 assert!(!cfg.force_path_style);
410 assert!(cfg.access_key_id.is_none());
411 assert!(cfg.region.is_none());
412 }
413
414 #[test]
415 fn config_deserializes_full() {
416 let json = r#"{
417 "bucket": "assets",
418 "region": "us-east-1",
419 "endpoint": "http://minio:9000",
420 "prefix": "uploads",
421 "force_path_style": true,
422 "access_key_id": "minio",
423 "secret_access_key": "minio123"
424 }"#;
425 let cfg: Config = serde_json::from_str(json).unwrap();
426 assert_eq!(cfg.bucket, "assets");
427 assert!(cfg.force_path_style);
428 assert_eq!(cfg.access_key_id.as_deref(), Some("minio"));
429 }
430
431 #[test]
432 fn config_debug_redacts_credentials() {
433 let cfg = Config {
434 bucket: "assets".into(),
435 region: Some("us-east-1".into()),
436 endpoint: Some("https://s3.example.test".into()),
437 prefix: Some("uploads".into()),
438 force_path_style: true,
439 access_key_id: Some("access-key".into()),
440 secret_access_key: Some("secret-key".into()),
441 };
442
443 let debug = format!("{cfg:?}");
444
445 assert!(debug.contains("<redacted>"));
446 assert!(!debug.contains("access-key"));
447 assert!(!debug.contains("secret-key"));
448 assert!(debug.contains("assets"));
449 }
450
451 #[test]
452 fn config_debug_omits_redaction_marker_without_credentials() {
453 let cfg = Config {
454 bucket: "assets".into(),
455 region: None,
456 endpoint: None,
457 prefix: None,
458 force_path_style: false,
459 access_key_id: None,
460 secret_access_key: None,
461 };
462
463 let debug = format!("{cfg:?}");
464
465 assert!(debug.contains("access_key_id: None"));
466 assert!(debug.contains("secret_access_key: None"));
467 assert!(!debug.contains("<redacted>"));
468 }
469
470 #[test]
471 fn resolve_explicit_credentials() {
472 let cfg = Config {
473 bucket: "test".into(),
474 region: None,
475 endpoint: None,
476 prefix: None,
477 force_path_style: false,
478 access_key_id: Some("key123".into()),
479 secret_access_key: Some("secret456".into()),
480 };
481 let (key, secret) = resolve_credentials(&cfg).unwrap();
482 assert_eq!(key, "key123");
483 assert_eq!(secret, "secret456");
484 }
485
486 #[test]
487 fn resolve_blank_explicit_credentials_falls_back_to_error_without_env() {
488 let cfg = Config {
489 bucket: "test".into(),
490 region: None,
491 endpoint: None,
492 prefix: None,
493 force_path_style: false,
494 access_key_id: Some(String::new()),
495 secret_access_key: Some(String::new()),
496 };
497
498 let err = resolve_credentials_with(&cfg, |_| None).unwrap_err();
499
500 assert_eq!(err.code(), ErrorCode::MissingField);
501 }
502
503 #[test]
504 fn resolve_empty_credentials_errors() {
505 let cfg = Config {
506 bucket: "test".into(),
507 region: None,
508 endpoint: None,
509 prefix: None,
510 force_path_style: false,
511 access_key_id: None,
512 secret_access_key: None,
513 };
514 let err = resolve_credentials_with(&cfg, |_| None).unwrap_err();
515
516 assert_eq!(err.code(), ErrorCode::MissingField);
517 }
518
519 #[test]
520 fn resolve_credentials_uses_environment_when_explicit_credentials_absent() {
521 let cfg = Config {
522 bucket: "test".into(),
523 region: None,
524 endpoint: None,
525 prefix: None,
526 force_path_style: false,
527 access_key_id: None,
528 secret_access_key: None,
529 };
530
531 let (key, secret) = resolve_credentials_with(&cfg, |name| match name {
532 "AWS_ACCESS_KEY_ID" => Some("env-key".to_string()),
533 "AWS_SECRET_ACCESS_KEY" => Some("env-secret".to_string()),
534 _ => None,
535 })
536 .unwrap();
537
538 assert_eq!(key, "env-key");
539 assert_eq!(secret, "env-secret");
540 }
541
542 #[test]
543 fn full_key_with_prefix() {
544 let store = S3Store::new(Config {
545 bucket: "b".into(),
546 region: None,
547 endpoint: None,
548 prefix: Some("pfx".into()),
549 force_path_style: false,
550 access_key_id: Some("k".into()),
551 secret_access_key: Some("s".into()),
552 })
553 .unwrap();
554 assert_eq!(store.full_key("file.txt"), "pfx/file.txt");
555 }
556
557 #[test]
558 fn full_key_with_slash_only_prefix() {
559 let store = S3Store::new(Config {
560 bucket: "b".into(),
561 region: None,
562 endpoint: None,
563 prefix: Some("///".into()),
564 force_path_style: false,
565 access_key_id: Some("k".into()),
566 secret_access_key: Some("s".into()),
567 })
568 .unwrap();
569
570 assert_eq!(store.full_key("file.txt"), "file.txt");
571 }
572
573 #[test]
574 fn full_key_without_prefix() {
575 let store = S3Store::new(Config {
576 bucket: "b".into(),
577 region: None,
578 endpoint: None,
579 prefix: None,
580 force_path_style: false,
581 access_key_id: Some("k".into()),
582 secret_access_key: Some("s".into()),
583 })
584 .unwrap();
585 assert_eq!(store.full_key("file.txt"), "file.txt");
586 }
587
588 #[test]
589 fn register_rejects_duplicate_s3_backend() {
590 let mut registry = StorageRegistry::new();
591 let config = Config {
592 bucket: "b".into(),
593 region: None,
594 endpoint: None,
595 prefix: None,
596 force_path_style: false,
597 access_key_id: Some("k".into()),
598 secret_access_key: Some("s".into()),
599 };
600
601 register(&mut registry, config.clone()).unwrap();
602 let err = register(&mut registry, config).unwrap_err();
603
604 assert_eq!(err.code(), ErrorCode::AlreadyExists);
605 }
606
607 #[test]
608 fn store_construction_applies_region_endpoint_and_path_style() {
609 let store = S3Store::new(Config {
610 bucket: "b".into(),
611 region: Some("us-east-1".into()),
612 endpoint: Some("http://127.0.0.1:9000".into()),
613 prefix: Some("uploads".into()),
614 force_path_style: true,
615 access_key_id: Some("k".into()),
616 secret_access_key: Some("s".into()),
617 })
618 .unwrap();
619
620 assert_eq!(store.full_key("file.txt"), "uploads/file.txt");
621 }
622
623 #[test]
624 fn constructs_offline_without_building_client() {
625 let store = S3Store::new(Config {
626 bucket: "b".into(),
627 region: Some("us-east-1".into()),
628 endpoint: Some("http://127.0.0.1:9000".into()),
629 prefix: Some("uploads".into()),
630 force_path_style: true,
631 access_key_id: Some("k".into()),
632 secret_access_key: Some("s".into()),
633 })
634 .unwrap();
635
636 assert_eq!(store.full_key("file.txt"), "uploads/file.txt");
637 }
638
639 #[tokio::test]
640 async fn factory_creates_store_from_explicit_credentials() {
641 let factory = S3Factory {
642 config: Config {
643 bucket: "b".into(),
644 region: Some("us-east-1".into()),
645 endpoint: Some("http://127.0.0.1:9000".into()),
646 prefix: None,
647 force_path_style: true,
648 access_key_id: Some("k".into()),
649 secret_access_key: Some("s".into()),
650 },
651 };
652
653 factory.create(&StorageConfig::default()).await.unwrap();
654 }
655
656 #[tokio::test]
657 async fn presigned_url_validates_duration_and_uses_configured_key_prefix() {
658 let store = S3Store::new(Config {
659 bucket: "bucket".into(),
660 region: Some("us-east-1".into()),
661 endpoint: Some("http://127.0.0.1:9000".into()),
662 prefix: Some("uploads".into()),
663 force_path_style: true,
664 access_key_id: Some("access".into()),
665 secret_access_key: Some("secret".into()),
666 })
667 .unwrap();
668
669 let too_long = store
670 .presigned_url("file.txt", Duration::from_secs(60 * 60 * 24 * 8))
671 .await
672 .unwrap_err();
673 assert_eq!(too_long.code(), ErrorCode::InvalidInput);
674
675 let url = store
676 .presigned_url("file.txt", Duration::from_secs(60))
677 .await
678 .unwrap();
679 assert!(url.contains("uploads/file.txt"));
680 }
681
682 #[test]
683 fn stored_file_mappers_preserve_keys_sizes_content_types_and_metadata() {
684 let mut metadata = HashMap::new();
685 metadata.insert("owner".to_string(), "media".to_string());
686 let uploaded = uploaded_file("file.txt", 4, Some("text/plain"), metadata.clone());
687 assert_eq!(uploaded.key, "file.txt");
688 assert_eq!(uploaded.size, 4);
689 assert_eq!(uploaded.content_type, "text/plain");
690 assert_eq!(uploaded.metadata, metadata);
691
692 let head = aws_sdk_s3::operation::head_object::HeadObjectOutput::builder()
693 .content_length(9)
694 .content_type("application/json")
695 .metadata("trace", "abc")
696 .build();
697 let from_head = stored_file_from_head("meta.json", &head);
698 assert_eq!(from_head.key, "meta.json");
699 assert_eq!(from_head.size, 9);
700 assert_eq!(from_head.content_type, "application/json");
701 assert_eq!(
702 from_head.metadata.get("trace").map(String::as_str),
703 Some("abc")
704 );
705
706 let object = aws_sdk_s3::types::Object::builder()
707 .key("uploads/a.txt")
708 .size(12)
709 .build();
710 let from_object = stored_file_from_object(&object);
711 assert_eq!(from_object.key, "uploads/a.txt");
712 assert_eq!(from_object.size, 12);
713
714 let empty_object = aws_sdk_s3::types::Object::builder().build();
715 let from_empty_object = stored_file_from_object(&empty_object);
716 assert_eq!(from_empty_object.key, "");
717 assert_eq!(from_empty_object.size, 0);
718 }
719
720 use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
727 use aws_smithy_types::body::SdkBody;
728
729 fn wire_config(prefix: Option<&str>) -> Config {
730 Config {
731 bucket: "test-bucket".into(),
732 region: Some("us-east-1".into()),
733 endpoint: Some("http://s3.local".into()),
734 prefix: prefix.map(str::to_owned),
735 force_path_style: true,
736 access_key_id: Some("test-key".into()),
737 secret_access_key: Some("test-secret".into()),
738 }
739 }
740
741 fn wire_store(config: Config, events: Vec<ReplayEvent>) -> (S3Store, StaticReplayClient) {
743 let http = StaticReplayClient::new(events);
744 let conf = client_config_builder(&config)
745 .unwrap()
746 .http_client(http.clone())
747 .build();
748 (
749 S3Store {
750 client: OnceCell::new_with(Some(aws_sdk_s3::Client::from_conf(conf))),
751 builder: Box::new(|| {
752 Box::pin(async {
753 Err(AppError::new(
754 ErrorCode::Internal,
755 "test S3 client builder should not be called",
756 ))
757 })
758 }),
759 config,
760 },
761 http,
762 )
763 }
764
765 fn ok_response(status: u16, body: impl Into<SdkBody>) -> ReplayEvent {
766 ReplayEvent::new(
767 http::Request::builder().body(SdkBody::empty()).unwrap(),
768 http::Response::builder()
769 .status(status)
770 .body(body.into())
771 .unwrap(),
772 )
773 }
774
775 fn error_response(status: u16, code: &str) -> ReplayEvent {
776 let body = format!(
777 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
778 <Error><Code>{code}</Code><Message>mock failure</Message>\
779 <RequestId>req-1</RequestId></Error>"
780 );
781 ok_response(status, SdkBody::from(body))
782 }
783
784 #[tokio::test]
785 async fn upload_puts_prefixed_object_and_returns_logical_key() {
786 let (store, http) = wire_store(wire_config(Some("uploads")), vec![ok_response(200, "")]);
787 let mut metadata = HashMap::new();
788 metadata.insert("owner".to_string(), "media".to_string());
789
790 let stored = store
791 .upload(
792 &FileSource::Bytes(bytes::Bytes::from_static(b"payload")),
793 "file.txt",
794 UploadOptions::new()
795 .with_content_type("text/plain")
796 .with_metadata(metadata),
797 )
798 .await
799 .unwrap();
800
801 assert_eq!(stored.key, "file.txt");
802 assert_eq!(stored.size, 7);
803 assert_eq!(stored.content_type, "text/plain");
804 assert_eq!(
805 stored.metadata.get("owner").map(String::as_str),
806 Some("media")
807 );
808
809 let request = http.actual_requests().next().expect("a request was sent");
810 assert_eq!(request.method(), "PUT");
811 assert!(
812 request.uri().contains("/test-bucket/uploads/file.txt"),
813 "unexpected upload uri: {}",
814 request.uri()
815 );
816 }
817
818 #[tokio::test]
819 async fn upload_maps_remote_failure_to_internal_error() {
820 let (store, _http) = wire_store(
821 wire_config(None),
822 vec![error_response(500, "InternalError")],
823 );
824
825 let err = store
826 .upload(
827 &FileSource::Bytes(bytes::Bytes::from_static(b"x")),
828 "file.txt",
829 UploadOptions::new(),
830 )
831 .await
832 .unwrap_err();
833
834 assert_eq!(err.code(), ErrorCode::Internal);
835 assert!(err.message().contains("S3 upload failed"));
836 }
837
838 #[tokio::test]
839 async fn download_collects_object_body_into_bytes() {
840 let (store, http) = wire_store(wire_config(None), vec![ok_response(200, "downloaded")]);
841
842 let source = store.download("file.txt").await.unwrap();
843 let data = source.read_all().await.unwrap();
844 assert_eq!(data.as_ref(), b"downloaded");
845
846 let request = http.actual_requests().next().unwrap();
847 assert_eq!(request.method(), "GET");
848 assert!(request.uri().contains("/test-bucket/file.txt"));
849 }
850
851 #[tokio::test]
852 async fn download_maps_missing_object_to_not_found() {
853 let (store, _http) = wire_store(wire_config(None), vec![error_response(404, "NoSuchKey")]);
854
855 let err = store.download("missing.txt").await.unwrap_err();
856
857 assert_eq!(err.code(), ErrorCode::NotFound);
858 assert!(err.message().contains("S3 download failed"));
859 }
860
861 #[tokio::test]
862 async fn delete_sends_delete_object_request() {
863 let (store, http) = wire_store(wire_config(None), vec![ok_response(204, "")]);
864
865 store.delete("file.txt").await.unwrap();
866
867 let request = http.actual_requests().next().unwrap();
868 assert_eq!(request.method(), "DELETE");
869 assert!(request.uri().contains("/test-bucket/file.txt"));
870 }
871
872 #[tokio::test]
873 async fn delete_maps_remote_failure_to_internal_error() {
874 let (store, _http) =
875 wire_store(wire_config(None), vec![error_response(403, "AccessDenied")]);
876
877 let err = store.delete("file.txt").await.unwrap_err();
878
879 assert_eq!(err.code(), ErrorCode::Internal);
880 assert!(err.message().contains("S3 delete failed"));
881 }
882
883 #[tokio::test]
884 async fn exists_reports_presence_from_head_outcome() {
885 let (present, _) = wire_store(wire_config(None), vec![ok_response(200, "")]);
886 assert!(present.exists("file.txt").await.unwrap());
887
888 let (absent, _) = wire_store(wire_config(None), vec![error_response(404, "NotFound")]);
889 assert!(!absent.exists("file.txt").await.unwrap());
890 }
891
892 #[tokio::test]
893 async fn head_maps_headers_to_stored_file_metadata() {
894 let response = ReplayEvent::new(
895 http::Request::builder().body(SdkBody::empty()).unwrap(),
896 http::Response::builder()
897 .status(200)
898 .header("content-length", "9")
899 .header("content-type", "application/json")
900 .header("x-amz-meta-trace", "abc")
901 .body(SdkBody::empty())
902 .unwrap(),
903 );
904 let (store, _http) = wire_store(wire_config(Some("uploads")), vec![response]);
905
906 let stored = store.head("meta.json").await.unwrap();
907
908 assert_eq!(stored.key, "meta.json");
909 assert_eq!(stored.size, 9);
910 assert_eq!(stored.content_type, "application/json");
911 assert_eq!(
912 stored.metadata.get("trace").map(String::as_str),
913 Some("abc")
914 );
915 }
916
917 #[tokio::test]
918 async fn head_maps_missing_object_to_not_found() {
919 let (store, _http) = wire_store(wire_config(None), vec![error_response(404, "NoSuchKey")]);
920
921 let err = store.head("missing.txt").await.unwrap_err();
922
923 assert_eq!(err.code(), ErrorCode::NotFound);
924 assert!(err.message().contains("S3 head failed"));
925 }
926
927 #[tokio::test]
928 async fn list_parses_contents_and_sends_prefix() {
929 let body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
930 <ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
931 <Name>test-bucket</Name><Prefix>uploads/logs</Prefix>\
932 <KeyCount>2</KeyCount><MaxKeys>2</MaxKeys><IsTruncated>false</IsTruncated>\
933 <Contents><Key>uploads/logs/a.txt</Key><Size>12</Size>\
934 <LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents>\
935 <Contents><Key>uploads/logs/b.txt</Key><Size>34</Size>\
936 <LastModified>2024-01-01T00:00:00.000Z</LastModified></Contents>\
937 </ListBucketResult>";
938 let (store, http) = wire_store(
939 wire_config(Some("uploads")),
940 vec![ok_response(200, SdkBody::from(body))],
941 );
942
943 let items = store.list("logs", Some(2)).await.unwrap();
944
945 assert_eq!(items.len(), 2);
946 assert_eq!(items[0].key, "uploads/logs/a.txt");
947 assert_eq!(items[0].size, 12);
948 assert_eq!(items[1].size, 34);
949
950 let request = http.actual_requests().next().unwrap();
951 assert!(request.uri().contains("prefix=uploads%2Flogs"));
952 assert!(request.uri().contains("max-keys=2"));
953 }
954
955 #[tokio::test]
956 async fn list_maps_remote_failure_to_internal_error() {
957 let (store, _http) = wire_store(
958 wire_config(None),
959 vec![error_response(500, "InternalError")],
960 );
961
962 let err = store.list("logs", None).await.unwrap_err();
963
964 assert_eq!(err.code(), ErrorCode::Internal);
965 assert!(err.message().contains("S3 list failed"));
966 }
967
968 #[tokio::test]
969 async fn copy_copies_source_then_heads_destination() {
970 let copy_body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
971 <CopyObjectResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
972 <ETag>\"abc\"</ETag><LastModified>2024-01-01T00:00:00.000Z</LastModified>\
973 </CopyObjectResult>";
974 let head = ReplayEvent::new(
975 http::Request::builder().body(SdkBody::empty()).unwrap(),
976 http::Response::builder()
977 .status(200)
978 .header("content-length", "5")
979 .header("content-type", "text/plain")
980 .body(SdkBody::empty())
981 .unwrap(),
982 );
983 let (store, http) = wire_store(
984 wire_config(Some("uploads")),
985 vec![ok_response(200, SdkBody::from(copy_body)), head],
986 );
987
988 let stored = store.copy("a.txt", "b.txt").await.unwrap();
989
990 assert_eq!(stored.key, "b.txt");
991 assert_eq!(stored.size, 5);
992
993 let mut requests = http.actual_requests();
994 let copy = requests.next().unwrap();
995 assert_eq!(copy.method(), "PUT");
996 assert!(copy.uri().contains("/test-bucket/uploads/b.txt"));
997 assert_eq!(
998 copy.headers().get("x-amz-copy-source"),
999 Some("test-bucket/uploads/a.txt")
1000 );
1001 assert_eq!(requests.next().unwrap().method(), "HEAD");
1002 }
1003
1004 #[tokio::test]
1005 async fn copy_maps_remote_failure_to_internal_error() {
1006 let (store, _http) = wire_store(wire_config(None), vec![error_response(404, "NoSuchKey")]);
1007
1008 let err = store.copy("a.txt", "b.txt").await.unwrap_err();
1009
1010 assert_eq!(err.code(), ErrorCode::Internal);
1011 assert!(err.message().contains("S3 copy failed"));
1012 }
1013
1014 #[tokio::test]
1015 async fn rename_copies_then_deletes_source() {
1016 let copy_body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1017 <CopyObjectResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
1018 <ETag>\"abc\"</ETag><LastModified>2024-01-01T00:00:00.000Z</LastModified>\
1019 </CopyObjectResult>";
1020 let head = ReplayEvent::new(
1021 http::Request::builder().body(SdkBody::empty()).unwrap(),
1022 http::Response::builder()
1023 .status(200)
1024 .header("content-length", "5")
1025 .body(SdkBody::empty())
1026 .unwrap(),
1027 );
1028 let (store, http) = wire_store(
1029 wire_config(None),
1030 vec![
1031 ok_response(200, SdkBody::from(copy_body)),
1032 head,
1033 ok_response(204, ""),
1034 ],
1035 );
1036
1037 let stored = store.rename("a.txt", "b.txt").await.unwrap();
1038
1039 assert_eq!(stored.key, "b.txt");
1040
1041 let methods: Vec<String> = http
1042 .actual_requests()
1043 .map(|r| r.method().to_owned())
1044 .collect();
1045 assert_eq!(
1046 methods.iter().map(String::as_str).collect::<Vec<_>>(),
1047 ["PUT", "HEAD", "DELETE"]
1048 );
1049 }
1050}