1use crate::sigv4::{self, Signing};
8use std::time::Duration;
9use crate::{Entry, Storage, Visibility, content_type, normalize};
10use rustlavel_client::Client;
11use rustlavel_core::{Config, Error, Result};
12
13#[derive(Debug, Clone)]
14pub struct S3Config {
15 pub bucket: String,
16 pub region: String,
17 pub access_key: String,
18 pub secret_key: String,
19 pub endpoint: Option<String>,
21 pub path_style: bool,
24 pub public_url: Option<String>,
26}
27
28impl Default for S3Config {
29 fn default() -> Self {
30 S3Config {
31 bucket: String::new(),
32 region: "us-east-1".into(),
33 access_key: String::new(),
34 secret_key: String::new(),
35 endpoint: None,
36 path_style: false,
37 public_url: None,
38 }
39 }
40}
41
42impl S3Config {
43 pub fn from_config(config: &Config) -> Result<S3Config> {
44 let settings = S3Config {
45 bucket: config.string("storage.bucket", ""),
46 region: config.string("storage.region", "us-east-1"),
47 access_key: config.string("storage.access_key", ""),
48 secret_key: config.string("storage.secret_key", ""),
49 endpoint: non_empty(config.string("storage.endpoint", "")),
50 path_style: config.bool("storage.path_style", false),
51 public_url: non_empty(config.string("storage.public_url", "")),
52 };
53
54 if settings.bucket.is_empty() {
55 return Err(Error::msg(
56 "the s3 storage driver needs `storage.bucket`. Set it in config/storage.json or \
57 point STORAGE_BUCKET at it in .env."
58 .to_string(),
59 ));
60 }
61 Ok(settings)
62 }
63
64 fn host(&self) -> String {
66 match &self.endpoint {
67 Some(endpoint) => endpoint
68 .trim_start_matches("https://")
69 .trim_start_matches("http://")
70 .trim_end_matches('/')
71 .to_string(),
72 None if self.path_style => format!("s3.{}.amazonaws.com", self.region),
73 None => format!("{}.s3.{}.amazonaws.com", self.bucket, self.region),
74 }
75 }
76
77 fn scheme(&self) -> &'static str {
78 match &self.endpoint {
79 Some(endpoint) if endpoint.starts_with("http://") => "http",
80 _ => "https",
81 }
82 }
83
84 fn path_for(&self, key: &str) -> String {
86 if self.path_style || self.endpoint.is_some() {
87 format!("/{}/{key}", self.bucket)
88 } else {
89 format!("/{key}")
90 }
91 }
92}
93
94fn non_empty(value: String) -> Option<String> {
95 (!value.is_empty()).then_some(value)
96}
97
98pub struct S3Storage {
99 config: S3Config,
100 client: Client,
101}
102
103impl S3Storage {
104 pub fn new(config: S3Config) -> Self {
105 S3Storage { config, client: Client::new().retries(2) }
106 }
107
108 pub fn with_client(config: S3Config, client: Client) -> Self {
110 S3Storage { config, client }
111 }
112
113 pub fn config(&self) -> &S3Config {
114 &self.config
115 }
116
117 fn signed(
119 &self,
120 method: &str,
121 key: &str,
122 query: &str,
123 body: &[u8],
124 extra: &[(String, String)],
125 ) -> (String, Vec<(String, String)>) {
126 let host = self.config.host();
127 let path = self.config.path_for(key);
128 let timestamp = timestamp();
129 let payload_hash = sigv4::sha256_hex(body);
130
131 let mut headers = vec![
132 ("host".to_string(), host.clone()),
133 ("x-amz-content-sha256".to_string(), payload_hash.clone()),
134 ("x-amz-date".to_string(), timestamp.clone()),
135 ];
136 headers.extend_from_slice(extra);
137
138 let signing = Signing {
139 access_key: &self.config.access_key,
140 secret_key: &self.config.secret_key,
141 region: &self.config.region,
142 service: "s3",
143 timestamp: ×tamp,
144 };
145
146 let authorization =
147 sigv4::authorization(&signing, method, &path, query, &headers, &payload_hash);
148 headers.push(("authorization".to_string(), authorization));
149
150 let url = if query.is_empty() {
151 format!("{}://{host}{}", self.config.scheme(), sigv4::encode_path(&path))
152 } else {
153 format!("{}://{host}{}?{query}", self.config.scheme(), sigv4::encode_path(&path))
154 };
155
156 (url, headers)
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Multipart {
167 pub key: String,
168 pub upload_id: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Part {
174 pub number: u32,
176 pub etag: String,
178}
179
180pub const MIN_PART_BYTES: u64 = 5 * 1024 * 1024;
182pub const MAX_PARTS: u32 = 10_000;
184pub const MAX_PRESIGN: Duration = Duration::from_secs(7 * 24 * 60 * 60);
186
187impl S3Storage {
188 fn signing<'a>(&'a self, timestamp: &'a str) -> Signing<'a> {
189 Signing {
190 access_key: &self.config.access_key,
191 secret_key: &self.config.secret_key,
192 region: &self.config.region,
193 service: "s3",
194 timestamp,
195 }
196 }
197
198 fn presigned(&self, method: &str, key: &str, expires: Duration, extra: &[(&str, &str)]) -> Result<String> {
201 if expires > MAX_PRESIGN {
202 return Err(Error::msg(format!(
203 "a presigned URL may be valid for at most seven days; {} seconds was asked for",
204 expires.as_secs()
205 )));
206 }
207 let key = normalize(key)?;
208 let host = self.config.host();
209 let path = self.config.path_for(&key);
210 let timestamp = timestamp();
211 let signing = self.signing(×tamp);
212
213 let query = sigv4::presigned_query(&signing, method, &host, &path, expires.as_secs(), extra);
214 Ok(format!("{}://{host}{}?{query}", self.config.scheme(), sigv4::encode_path(&path)))
215 }
216
217 pub fn presigned_put(&self, key: &str, expires: Duration) -> Result<String> {
229 self.presigned("PUT", key, expires, &[])
230 }
231
232 pub fn presigned_get(&self, key: &str, expires: Duration) -> Result<String> {
235 self.presigned("GET", key, expires, &[])
236 }
237
238 pub async fn create_multipart(&self, key: &str) -> Result<Multipart> {
240 let key = normalize(key)?;
241 let extra = vec![("content-type".to_string(), content_type(&key).to_string())];
242 let (url, headers) = self.signed("POST", &key, "uploads=", b"", &extra);
243
244 let mut request = self.client.post(url);
245 for (name, value) in headers {
246 request = request.header(&name, value);
247 }
248 let body = request.send().await?.error_for_status()?.text();
249
250 let upload_id = between(&body, "<UploadId>", "</UploadId>")
251 .map(unescape_xml)
252 .ok_or_else(|| Error::msg("the bucket began a multipart upload and sent no UploadId"))?;
253 Ok(Multipart { key, upload_id })
254 }
255
256 pub fn presigned_part(&self, upload: &Multipart, number: u32, expires: Duration) -> Result<String> {
261 if number == 0 || number > MAX_PARTS {
262 return Err(Error::msg(format!(
263 "part numbers run from 1 to {MAX_PARTS}; {number} is not one"
264 )));
265 }
266 let number = number.to_string();
267 self.presigned(
268 "PUT",
269 &upload.key,
270 expires,
271 &[("partNumber", &number), ("uploadId", &upload.upload_id)],
272 )
273 }
274
275 pub async fn complete_multipart(&self, upload: &Multipart, mut parts: Vec<Part>) -> Result<()> {
278 if parts.is_empty() {
279 return Err(Error::msg("a multipart upload cannot be completed with no parts"));
280 }
281 parts.sort_by_key(|part| part.number);
282
283 let mut body = String::from("<CompleteMultipartUpload>");
284 for part in &parts {
285 let etag = part.etag.trim_matches('"');
288 body.push_str(&format!(
289 "<Part><PartNumber>{}</PartNumber><ETag>\"{etag}\"</ETag></Part>",
290 part.number
291 ));
292 }
293 body.push_str("</CompleteMultipartUpload>");
294
295 let query = format!("uploadId={}", sigv4::encode_segment(&upload.upload_id));
296 let extra = vec![("content-type".to_string(), "application/xml".to_string())];
297 let (url, headers) = self.signed("POST", &upload.key, &query, body.as_bytes(), &extra);
298
299 let mut request = self.client.post(url).body(body.into_bytes());
300 for (name, value) in headers {
301 request = request.header(&name, value);
302 }
303 let response = request.send().await?.error_for_status()?;
304
305 let text = response.text();
309 if text.contains("<Error>") {
310 let code = between(&text, "<Code>", "</Code>").unwrap_or("unknown");
311 return Err(Error::msg(format!("completing the multipart upload failed: {code}")));
312 }
313 Ok(())
314 }
315
316 pub async fn abort_multipart(&self, upload: &Multipart) -> Result<()> {
321 let query = format!("uploadId={}", sigv4::encode_segment(&upload.upload_id));
322 let (url, headers) = self.signed("DELETE", &upload.key, &query, b"", &[]);
323
324 let mut request = self.client.delete(url);
325 for (name, value) in headers {
326 request = request.header(&name, value);
327 }
328 request.send().await?.error_for_status()?;
329 Ok(())
330 }
331
332 pub async fn expire_after(&self, prefix: &str, days: u32) -> Result<()> {
341 if prefix.trim().is_empty() {
342 return Err(Error::msg(
343 "an expiry rule needs a prefix: one for the whole bucket deletes everything in it",
344 ));
345 }
346 if days == 0 {
347 return Err(Error::msg("an expiry of zero days would delete objects as they arrive"));
348 }
349 let trailing = prefix.trim_end().ends_with('/');
355 let mut prefix = normalize(prefix)?;
356 if trailing {
357 prefix.push('/');
358 }
359
360 let body = format!(
361 "<LifecycleConfiguration><Rule><ID>rustlavel-expire-{}</ID><Filter><Prefix>{}</Prefix></Filter>\
362 <Status>Enabled</Status><Expiration><Days>{days}</Days></Expiration>\
363 <AbortIncompleteMultipartUpload><DaysAfterInitiation>{days}</DaysAfterInitiation>\
364 </AbortIncompleteMultipartUpload></Rule></LifecycleConfiguration>",
365 prefix.trim_end_matches('/').replace('/', "-"),
366 escape_xml(&prefix),
367 );
368
369 let extra = vec![
373 ("content-type".to_string(), "application/xml".to_string()),
374 ("content-md5".to_string(), md5_base64(body.as_bytes())),
375 ];
376 let (url, headers) = self.signed("PUT", "", "lifecycle=", body.as_bytes(), &extra);
377
378 let mut request = self.client.put(url).body(body.into_bytes());
379 for (name, value) in headers {
380 request = request.header(&name, value);
381 }
382 request.send().await?.error_for_status()?;
383 Ok(())
384 }
385}
386
387impl Storage for S3Storage {
388 async fn put(&self, path: &str, contents: Vec<u8>) -> Result<()> {
389 self.put_with(path, contents, Visibility::Private).await
390 }
391
392 async fn put_with(&self, path: &str, contents: Vec<u8>, visibility: Visibility) -> Result<()> {
393 let key = normalize(path)?;
394 let mut extra = vec![("content-type".to_string(), content_type(&key).to_string())];
395 if visibility == Visibility::Public {
396 extra.push(("x-amz-acl".to_string(), "public-read".to_string()));
397 }
398
399 let (url, headers) = self.signed("PUT", &key, "", &contents, &extra);
400
401 let mut request = self.client.put(url).body(contents);
402 for (name, value) in headers {
403 request = request.header(&name, value);
404 }
405
406 request.send().await?.error_for_status()?;
407 Ok(())
408 }
409
410 async fn get(&self, path: &str) -> Result<Vec<u8>> {
411 let key = normalize(path)?;
412 let (url, headers) = self.signed("GET", &key, "", b"", &[]);
413
414 let mut request = self.client.get(url);
415 for (name, value) in headers {
416 request = request.header(&name, value);
417 }
418
419 let response = request.send().await?;
420 if response.status.code() == 404 {
421 return Err(Error::msg(format!("`{path}` is not in bucket {}", self.config.bucket)));
422 }
423 Ok(response.error_for_status()?.body)
424 }
425
426 async fn exists(&self, path: &str) -> Result<bool> {
427 let key = normalize(path)?;
428 let (url, headers) = self.signed("HEAD", &key, "", b"", &[]);
429
430 let mut request = self.client.request(rustlavel_http::Method::Head, url);
431 for (name, value) in headers {
432 request = request.header(&name, value);
433 }
434
435 Ok(request.send().await?.is_success())
436 }
437
438 async fn delete(&self, path: &str) -> Result<()> {
439 let key = normalize(path)?;
440 let (url, headers) = self.signed("DELETE", &key, "", b"", &[]);
441
442 let mut request = self.client.delete(url);
443 for (name, value) in headers {
444 request = request.header(&name, value);
445 }
446
447 let response = request.send().await?;
448 if response.status.code() == 404 || response.is_success() {
450 return Ok(());
451 }
452 response.error_for_status()?;
453 Ok(())
454 }
455
456 async fn size(&self, path: &str) -> Result<u64> {
457 let key = normalize(path)?;
458 let (url, headers) = self.signed("HEAD", &key, "", b"", &[]);
459
460 let mut request = self.client.request(rustlavel_http::Method::Head, url);
461 for (name, value) in headers {
462 request = request.header(&name, value);
463 }
464
465 let response = request.send().await?;
466 if !response.is_success() {
467 return Err(Error::msg(format!("`{path}` is not in bucket {}", self.config.bucket)));
468 }
469 Ok(response.headers.content_length().unwrap_or(0) as u64)
470 }
471
472 async fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
473 let query = format!(
475 "list-type=2&prefix={}",
476 sigv4::encode_segment(prefix.trim_matches('/'))
477 );
478 let (url, headers) = self.signed("GET", "", &query, b"", &[]);
479
480 let mut request = self.client.get(url);
481 for (name, value) in headers {
482 request = request.header(&name, value);
483 }
484
485 let response = request.send().await?.error_for_status()?;
486 Ok(parse_listing(&response.text()))
487 }
488
489 fn url(&self, path: &str) -> Option<String> {
490 let key = normalize(path).ok()?;
491
492 match &self.config.public_url {
493 Some(base) => Some(format!("{}/{key}", base.trim_end_matches('/'))),
494 None => Some(format!(
495 "{}://{}{}",
496 self.config.scheme(),
497 self.config.host(),
498 sigv4::encode_path(&self.config.path_for(&key))
499 )),
500 }
501 }
502}
503
504fn parse_listing(xml: &str) -> Vec<Entry> {
509 let mut entries = Vec::new();
510
511 for chunk in xml.split("<Contents>").skip(1) {
512 let Some(key) = between(chunk, "<Key>", "</Key>") else { continue };
513 let size = between(chunk, "<Size>", "</Size>")
514 .and_then(|s| s.trim().parse().ok())
515 .unwrap_or(0);
516
517 entries.push(Entry { path: unescape_xml(key), size, is_directory: false });
518 }
519
520 for chunk in xml.split("<CommonPrefixes>").skip(1) {
521 if let Some(prefix) = between(chunk, "<Prefix>", "</Prefix>") {
522 entries.push(Entry {
523 path: unescape_xml(prefix.trim_end_matches('/')),
524 size: 0,
525 is_directory: true,
526 });
527 }
528 }
529
530 entries.sort_by(|a, b| a.path.cmp(&b.path));
531 entries
532}
533
534fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
535 let start = text.find(open)? + open.len();
536 let end = text[start..].find(close)? + start;
537 Some(&text[start..end])
538}
539
540fn escape_xml(text: &str) -> String {
541 text.replace('&', "&").replace('<', "<").replace('>', ">")
542}
543
544fn md5_base64(bytes: &[u8]) -> String {
549 use md5::{Digest, Md5};
550 rustlavel_core::base64::encode(&Md5::digest(bytes))
551}
552
553fn unescape_xml(text: &str) -> String {
554 text.replace("&", "&")
555 .replace("<", "<")
556 .replace(">", ">")
557 .replace(""", "\"")
558 .replace("'", "'")
559}
560
561fn timestamp() -> String {
563 let now = std::time::SystemTime::now()
564 .duration_since(std::time::UNIX_EPOCH)
565 .map(|d| d.as_secs() as i64)
566 .unwrap_or(0);
567
568 let (year, month, day) = civil_from_days(now.div_euclid(86_400));
569 let seconds = now.rem_euclid(86_400);
570
571 format!(
572 "{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z",
573 seconds / 3600,
574 (seconds % 3600) / 60,
575 seconds % 60
576 )
577}
578
579fn civil_from_days(days: i64) -> (i64, u32, u32) {
580 let z = days + 719_468;
581 let era = z.div_euclid(146_097);
582 let day_of_era = z.rem_euclid(146_097);
583 let year_of_era =
584 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
585 let year = year_of_era + era * 400;
586 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
587 let mp = (5 * day_of_year + 2) / 153;
588 let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32;
589 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
590 (year + i64::from(month <= 2), month, day)
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use rustlavel_client::{Fake, FakeResponse};
597
598 fn config() -> S3Config {
599 S3Config {
600 bucket: "uploads".into(),
601 region: "eu-west-1".into(),
602 access_key: "AKIAEXAMPLE".into(),
603 secret_key: "secret".into(),
604 ..S3Config::default()
605 }
606 }
607
608 #[test]
609 fn addresses_aws_in_virtual_host_style_by_default() {
610 let storage = S3Storage::new(config());
611
612 assert_eq!(storage.config.host(), "uploads.s3.eu-west-1.amazonaws.com");
613 assert_eq!(storage.config.path_for("a/b.png"), "/a/b.png");
614 assert_eq!(
615 storage.url("a/b.png").as_deref(),
616 Some("https://uploads.s3.eu-west-1.amazonaws.com/a/b.png")
617 );
618 }
619
620 #[test]
621 fn a_custom_endpoint_switches_to_path_style() {
622 let storage = S3Storage::new(S3Config {
623 endpoint: Some("http://127.0.0.1:9000".into()),
624 ..config()
625 });
626
627 assert_eq!(storage.config.host(), "127.0.0.1:9000");
628 assert_eq!(storage.config.path_for("a.png"), "/uploads/a.png");
629 assert_eq!(storage.url("a.png").as_deref(), Some("http://127.0.0.1:9000/uploads/a.png"));
630 }
631
632 #[test]
633 fn a_public_url_overrides_the_api_host() {
634 let storage = S3Storage::new(S3Config {
635 public_url: Some("https://cdn.example.com".into()),
636 ..config()
637 });
638
639 assert_eq!(storage.url("a/b.png").as_deref(), Some("https://cdn.example.com/a/b.png"));
640 }
641
642 #[test]
646 fn a_presigned_put_carries_a_signature_and_no_secret() {
647 let storage = S3Storage::new(config());
648 let url = storage.presigned_put("videos/raw/abc.mp4", Duration::from_secs(900)).unwrap();
649
650 assert!(url.starts_with("https://uploads.s3.eu-west-1.amazonaws.com/videos/raw/abc.mp4?"), "{url}");
651 assert!(url.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"), "{url}");
652 assert!(url.contains("X-Amz-Credential=AKIAEXAMPLE%2F"), "{url}");
653 assert!(url.contains("X-Amz-Expires=900"), "{url}");
654 assert!(url.contains("X-Amz-SignedHeaders=host"), "{url}");
655 assert!(url.contains("&X-Amz-Signature="), "{url}");
656 assert!(!url.contains(&config().secret_key), "the secret is in the URL");
657 let signature = url.rsplit("X-Amz-Signature=").next().unwrap();
659 assert_eq!(signature.len(), 64, "{signature}");
660 assert!(signature.bytes().all(|b| b.is_ascii_hexdigit()));
661 }
662
663 #[test]
667 fn a_presigned_url_cannot_outlive_what_the_bucket_allows() {
668 let storage = S3Storage::new(config());
669 let error = storage
670 .presigned_put("x", MAX_PRESIGN + Duration::from_secs(1))
671 .unwrap_err()
672 .to_string();
673 assert!(error.contains("seven days"), "{error}");
674 assert!(storage.presigned_put("x", MAX_PRESIGN).is_ok());
675 }
676
677 #[test]
680 fn a_presigned_url_refuses_an_escaping_path() {
681 let storage = S3Storage::new(config());
682 assert!(storage.presigned_put("../other-bucket-key", Duration::from_secs(60)).is_err());
683 }
684
685 #[tokio::test]
686 async fn a_multipart_upload_is_begun_and_completed_in_the_shape_s3_expects() {
687 let client = Client::new().faking(
688 Fake::new()
689 .on("uploads=", FakeResponse::text(
690 "<InitiateMultipartUploadResult><Bucket>uploads</Bucket><Key>videos/big.mp4</Key>\
691 <UploadId>2~abc.DEF</UploadId></InitiateMultipartUploadResult>",
692 ))
693 .fallback(FakeResponse::text("<CompleteMultipartUploadResult/>")),
694 );
695 let storage = S3Storage::with_client(config(), client);
696
697 let upload = storage.create_multipart("videos/big.mp4").await.unwrap();
698 assert_eq!(upload.upload_id, "2~abc.DEF");
699 assert_eq!(upload.key, "videos/big.mp4");
700
701 let url = storage.presigned_part(&upload, 3, Duration::from_secs(600)).unwrap();
703 assert!(url.contains("partNumber=3"), "{url}");
704 assert!(url.contains("uploadId=2~abc.DEF"), "{url}");
705 assert!(url.contains("X-Amz-Signature="), "{url}");
706
707 storage
710 .complete_multipart(&upload, vec![
711 Part { number: 2, etag: "\"e2\"".into() },
712 Part { number: 1, etag: "e1".into() },
713 ])
714 .await
715 .unwrap();
716
717 let fake = storage.client.fake().unwrap();
718 let complete = fake.recorded().into_iter().last().unwrap();
719 assert!(complete.url.contains("uploadId=2~abc.DEF"), "{}", complete.url);
720 let body = complete.body_text();
721 let one = body.find("<PartNumber>1</PartNumber>").expect("part 1");
722 let two = body.find("<PartNumber>2</PartNumber>").expect("part 2");
723 assert!(one < two, "parts were not joined by number: {body}");
724 assert!(body.contains("<ETag>\"e1\"</ETag>"), "an unquoted ETag was sent bare: {body}");
725 assert!(body.contains("<ETag>\"e2\"</ETag>"), "a quoted ETag was double-quoted: {body}");
726 }
727
728 #[tokio::test]
731 async fn a_failure_hidden_in_a_200_is_still_a_failure() {
732 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text(
733 "<Error><Code>InvalidPart</Code><Message>One or more parts could not be found</Message></Error>",
734 )));
735 let storage = S3Storage::with_client(config(), client);
736 let upload = Multipart { key: "k".into(), upload_id: "u".into() };
737
738 let error = storage
739 .complete_multipart(&upload, vec![Part { number: 1, etag: "e".into() }])
740 .await
741 .unwrap_err()
742 .to_string();
743 assert!(error.contains("InvalidPart"), "{error}");
744 }
745
746 #[test]
747 fn a_part_number_outside_what_s3_accepts_is_refused_before_signing() {
748 let storage = S3Storage::new(config());
749 let upload = Multipart { key: "k".into(), upload_id: "u".into() };
750 assert!(storage.presigned_part(&upload, 0, Duration::from_secs(60)).is_err());
751 assert!(storage.presigned_part(&upload, MAX_PARTS + 1, Duration::from_secs(60)).is_err());
752 assert!(storage.presigned_part(&upload, MAX_PARTS, Duration::from_secs(60)).is_ok());
753 }
754
755 #[tokio::test]
758 async fn an_expiry_rule_needs_a_prefix_and_carries_the_checksum_s3_demands() {
759 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("")));
760 let storage = S3Storage::with_client(config(), client);
761
762 assert!(storage.expire_after("", 30).await.is_err(), "an empty prefix was accepted");
763 assert!(storage.expire_after(" ", 30).await.is_err());
764 assert!(storage.expire_after("tmp/", 0).await.is_err(), "zero days was accepted");
765
766 storage.expire_after("videos/raw/", 60).await.unwrap();
767
768 let fake = storage.client.fake().unwrap();
769 let sent = fake.recorded().into_iter().last().unwrap();
770 assert!(sent.url.ends_with("?lifecycle="), "{}", sent.url);
771 assert!(sent.headers.contains("content-md5"), "S3 refuses a lifecycle PUT without it");
772 let body = sent.body_text();
773 assert!(body.contains("<Prefix>videos/raw/</Prefix>"), "{body}");
774 assert!(body.contains("<Days>60</Days>"), "{body}");
775 assert!(body.contains("<DaysAfterInitiation>60</DaysAfterInitiation>"), "abandoned uploads are not cleaned up: {body}");
776 }
777
778 #[tokio::test]
779 async fn a_put_is_signed_and_typed() {
780 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("")));
781 let storage = S3Storage::with_client(config(), client);
782
783 storage.put("photos/holiday.png", b"bytes".to_vec()).await.unwrap();
784
785 let fake = storage.client.fake().unwrap();
786 let sent = &fake.recorded()[0];
787
788 assert_eq!(sent.url, "https://uploads.s3.eu-west-1.amazonaws.com/photos/holiday.png");
789 assert_eq!(sent.headers.get("content-type"), Some("image/png"));
790 assert!(sent.headers.get("authorization").unwrap().starts_with("AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/"));
791 assert!(sent.headers.contains("x-amz-content-sha256"));
792 assert_eq!(sent.headers.get("x-amz-acl"), None, "private by default");
793 }
794
795 #[tokio::test]
796 async fn a_public_put_asks_for_a_public_acl() {
797 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("")));
798 let storage = S3Storage::with_client(config(), client);
799
800 storage.put_with("a.txt", b"x".to_vec(), Visibility::Public).await.unwrap();
801
802 assert_eq!(
803 storage.client.fake().unwrap().recorded()[0].headers.get("x-amz-acl"),
804 Some("public-read")
805 );
806 }
807
808 #[tokio::test]
809 async fn a_missing_object_names_the_bucket() {
810 let client =
811 Client::new().faking(Fake::new().fallback(FakeResponse::text("no").status(404)));
812 let storage = S3Storage::with_client(config(), client);
813
814 let error = storage.get("gone.txt").await.unwrap_err().to_string();
815
816 assert!(error.contains("gone.txt"));
817 assert!(error.contains("uploads"));
818 }
819
820 #[tokio::test]
821 async fn deleting_something_absent_is_not_an_error() {
822 let client =
823 Client::new().faking(Fake::new().fallback(FakeResponse::text("no").status(404)));
824 let storage = S3Storage::with_client(config(), client);
825
826 assert!(storage.delete("gone.txt").await.is_ok());
827 }
828
829 #[tokio::test]
830 async fn a_listing_is_parsed_out_of_the_xml() {
831 let xml = r#"<?xml version="1.0"?>
832<ListBucketResult>
833 <Contents><Key>photos/a.png</Key><Size>1024</Size></Contents>
834 <Contents><Key>photos/b & c.png</Key><Size>2048</Size></Contents>
835 <CommonPrefixes><Prefix>photos/thumbs/</Prefix></CommonPrefixes>
836</ListBucketResult>"#;
837
838 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text(xml)));
839 let storage = S3Storage::with_client(config(), client);
840
841 let entries = storage.list("photos").await.unwrap();
842
843 assert_eq!(entries.len(), 3);
844 assert_eq!(entries[0].path, "photos/a.png");
845 assert_eq!(entries[0].size, 1024);
846 assert_eq!(entries[1].path, "photos/b & c.png");
847 assert!(entries[2].is_directory);
848 }
849
850 #[tokio::test]
851 async fn the_secret_key_never_reaches_a_request_or_an_error() {
852 let client =
853 Client::new().faking(Fake::new().fallback(FakeResponse::text("denied").status(403)));
854 let storage = S3Storage::with_client(config(), client);
855
856 let error = storage.get("a.txt").await.unwrap_err().to_string();
857 assert!(!error.contains("secret"));
858
859 let sent = &storage.client.fake().unwrap().recorded()[0];
860 for (_, value) in sent.headers.iter() {
861 assert!(!value.contains("secret"), "the secret key leaked into {value}");
862 }
863 }
864
865 #[test]
866 fn a_bucketless_configuration_says_what_to_set() {
867 let config = rustlavel_core::Config::new();
868 config.set("storage.driver", "s3");
869
870 let error = S3Config::from_config(&config).unwrap_err().to_string();
871 assert!(error.contains("storage.bucket"));
872 }
873
874 #[test]
875 fn timestamps_are_in_the_format_sigv4_requires() {
876 let stamp = timestamp();
877
878 assert_eq!(stamp.len(), 16);
879 assert!(stamp.ends_with('Z'));
880 assert_eq!(&stamp[8..9], "T");
881 }
882}