1use crate::sigv4::{self, Signing};
8use crate::{Entry, Storage, Visibility, content_type, normalize};
9use rustlavel_client::Client;
10use rustlavel_core::{Config, Error, Result};
11
12#[derive(Debug, Clone)]
13pub struct S3Config {
14 pub bucket: String,
15 pub region: String,
16 pub access_key: String,
17 pub secret_key: String,
18 pub endpoint: Option<String>,
20 pub path_style: bool,
23 pub public_url: Option<String>,
25}
26
27impl Default for S3Config {
28 fn default() -> Self {
29 S3Config {
30 bucket: String::new(),
31 region: "us-east-1".into(),
32 access_key: String::new(),
33 secret_key: String::new(),
34 endpoint: None,
35 path_style: false,
36 public_url: None,
37 }
38 }
39}
40
41impl S3Config {
42 pub fn from_config(config: &Config) -> Result<S3Config> {
43 let settings = S3Config {
44 bucket: config.string("storage.bucket", ""),
45 region: config.string("storage.region", "us-east-1"),
46 access_key: config.string("storage.access_key", ""),
47 secret_key: config.string("storage.secret_key", ""),
48 endpoint: non_empty(config.string("storage.endpoint", "")),
49 path_style: config.bool("storage.path_style", false),
50 public_url: non_empty(config.string("storage.public_url", "")),
51 };
52
53 if settings.bucket.is_empty() {
54 return Err(Error::msg(
55 "the s3 storage driver needs `storage.bucket`. Set it in config/storage.json or \
56 point STORAGE_BUCKET at it in .env."
57 .to_string(),
58 ));
59 }
60 Ok(settings)
61 }
62
63 fn host(&self) -> String {
65 match &self.endpoint {
66 Some(endpoint) => endpoint
67 .trim_start_matches("https://")
68 .trim_start_matches("http://")
69 .trim_end_matches('/')
70 .to_string(),
71 None if self.path_style => format!("s3.{}.amazonaws.com", self.region),
72 None => format!("{}.s3.{}.amazonaws.com", self.bucket, self.region),
73 }
74 }
75
76 fn scheme(&self) -> &'static str {
77 match &self.endpoint {
78 Some(endpoint) if endpoint.starts_with("http://") => "http",
79 _ => "https",
80 }
81 }
82
83 fn path_for(&self, key: &str) -> String {
85 if self.path_style || self.endpoint.is_some() {
86 format!("/{}/{key}", self.bucket)
87 } else {
88 format!("/{key}")
89 }
90 }
91}
92
93fn non_empty(value: String) -> Option<String> {
94 (!value.is_empty()).then_some(value)
95}
96
97pub struct S3Storage {
98 config: S3Config,
99 client: Client,
100}
101
102impl S3Storage {
103 pub fn new(config: S3Config) -> Self {
104 S3Storage { config, client: Client::new().retries(2) }
105 }
106
107 pub fn with_client(config: S3Config, client: Client) -> Self {
109 S3Storage { config, client }
110 }
111
112 pub fn config(&self) -> &S3Config {
113 &self.config
114 }
115
116 fn signed(
118 &self,
119 method: &str,
120 key: &str,
121 query: &str,
122 body: &[u8],
123 extra: &[(String, String)],
124 ) -> (String, Vec<(String, String)>) {
125 let host = self.config.host();
126 let path = self.config.path_for(key);
127 let timestamp = timestamp();
128 let payload_hash = sigv4::sha256_hex(body);
129
130 let mut headers = vec![
131 ("host".to_string(), host.clone()),
132 ("x-amz-content-sha256".to_string(), payload_hash.clone()),
133 ("x-amz-date".to_string(), timestamp.clone()),
134 ];
135 headers.extend_from_slice(extra);
136
137 let signing = Signing {
138 access_key: &self.config.access_key,
139 secret_key: &self.config.secret_key,
140 region: &self.config.region,
141 service: "s3",
142 timestamp: ×tamp,
143 };
144
145 let authorization =
146 sigv4::authorization(&signing, method, &path, query, &headers, &payload_hash);
147 headers.push(("authorization".to_string(), authorization));
148
149 let url = if query.is_empty() {
150 format!("{}://{host}{}", self.config.scheme(), sigv4::encode_path(&path))
151 } else {
152 format!("{}://{host}{}?{query}", self.config.scheme(), sigv4::encode_path(&path))
153 };
154
155 (url, headers)
156 }
157}
158
159impl Storage for S3Storage {
160 async fn put(&self, path: &str, contents: Vec<u8>) -> Result<()> {
161 self.put_with(path, contents, Visibility::Private).await
162 }
163
164 async fn put_with(&self, path: &str, contents: Vec<u8>, visibility: Visibility) -> Result<()> {
165 let key = normalize(path)?;
166 let mut extra = vec![("content-type".to_string(), content_type(&key).to_string())];
167 if visibility == Visibility::Public {
168 extra.push(("x-amz-acl".to_string(), "public-read".to_string()));
169 }
170
171 let (url, headers) = self.signed("PUT", &key, "", &contents, &extra);
172
173 let mut request = self.client.put(url).body(contents);
174 for (name, value) in headers {
175 request = request.header(&name, value);
176 }
177
178 request.send().await?.error_for_status()?;
179 Ok(())
180 }
181
182 async fn get(&self, path: &str) -> Result<Vec<u8>> {
183 let key = normalize(path)?;
184 let (url, headers) = self.signed("GET", &key, "", b"", &[]);
185
186 let mut request = self.client.get(url);
187 for (name, value) in headers {
188 request = request.header(&name, value);
189 }
190
191 let response = request.send().await?;
192 if response.status.code() == 404 {
193 return Err(Error::msg(format!("`{path}` is not in bucket {}", self.config.bucket)));
194 }
195 Ok(response.error_for_status()?.body)
196 }
197
198 async fn exists(&self, path: &str) -> Result<bool> {
199 let key = normalize(path)?;
200 let (url, headers) = self.signed("HEAD", &key, "", b"", &[]);
201
202 let mut request = self.client.request(rustlavel_http::Method::Head, url);
203 for (name, value) in headers {
204 request = request.header(&name, value);
205 }
206
207 Ok(request.send().await?.is_success())
208 }
209
210 async fn delete(&self, path: &str) -> Result<()> {
211 let key = normalize(path)?;
212 let (url, headers) = self.signed("DELETE", &key, "", b"", &[]);
213
214 let mut request = self.client.delete(url);
215 for (name, value) in headers {
216 request = request.header(&name, value);
217 }
218
219 let response = request.send().await?;
220 if response.status.code() == 404 || response.is_success() {
222 return Ok(());
223 }
224 response.error_for_status()?;
225 Ok(())
226 }
227
228 async fn size(&self, path: &str) -> Result<u64> {
229 let key = normalize(path)?;
230 let (url, headers) = self.signed("HEAD", &key, "", b"", &[]);
231
232 let mut request = self.client.request(rustlavel_http::Method::Head, url);
233 for (name, value) in headers {
234 request = request.header(&name, value);
235 }
236
237 let response = request.send().await?;
238 if !response.is_success() {
239 return Err(Error::msg(format!("`{path}` is not in bucket {}", self.config.bucket)));
240 }
241 Ok(response.headers.content_length().unwrap_or(0) as u64)
242 }
243
244 async fn list(&self, prefix: &str) -> Result<Vec<Entry>> {
245 let query = format!(
247 "list-type=2&prefix={}",
248 sigv4::encode_segment(prefix.trim_matches('/'))
249 );
250 let (url, headers) = self.signed("GET", "", &query, b"", &[]);
251
252 let mut request = self.client.get(url);
253 for (name, value) in headers {
254 request = request.header(&name, value);
255 }
256
257 let response = request.send().await?.error_for_status()?;
258 Ok(parse_listing(&response.text()))
259 }
260
261 fn url(&self, path: &str) -> Option<String> {
262 let key = normalize(path).ok()?;
263
264 match &self.config.public_url {
265 Some(base) => Some(format!("{}/{key}", base.trim_end_matches('/'))),
266 None => Some(format!(
267 "{}://{}{}",
268 self.config.scheme(),
269 self.config.host(),
270 sigv4::encode_path(&self.config.path_for(&key))
271 )),
272 }
273 }
274}
275
276fn parse_listing(xml: &str) -> Vec<Entry> {
281 let mut entries = Vec::new();
282
283 for chunk in xml.split("<Contents>").skip(1) {
284 let Some(key) = between(chunk, "<Key>", "</Key>") else { continue };
285 let size = between(chunk, "<Size>", "</Size>")
286 .and_then(|s| s.trim().parse().ok())
287 .unwrap_or(0);
288
289 entries.push(Entry { path: unescape_xml(key), size, is_directory: false });
290 }
291
292 for chunk in xml.split("<CommonPrefixes>").skip(1) {
293 if let Some(prefix) = between(chunk, "<Prefix>", "</Prefix>") {
294 entries.push(Entry {
295 path: unescape_xml(prefix.trim_end_matches('/')),
296 size: 0,
297 is_directory: true,
298 });
299 }
300 }
301
302 entries.sort_by(|a, b| a.path.cmp(&b.path));
303 entries
304}
305
306fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
307 let start = text.find(open)? + open.len();
308 let end = text[start..].find(close)? + start;
309 Some(&text[start..end])
310}
311
312fn unescape_xml(text: &str) -> String {
313 text.replace("&", "&")
314 .replace("<", "<")
315 .replace(">", ">")
316 .replace(""", "\"")
317 .replace("'", "'")
318}
319
320fn timestamp() -> String {
322 let now = std::time::SystemTime::now()
323 .duration_since(std::time::UNIX_EPOCH)
324 .map(|d| d.as_secs() as i64)
325 .unwrap_or(0);
326
327 let (year, month, day) = civil_from_days(now.div_euclid(86_400));
328 let seconds = now.rem_euclid(86_400);
329
330 format!(
331 "{year:04}{month:02}{day:02}T{:02}{:02}{:02}Z",
332 seconds / 3600,
333 (seconds % 3600) / 60,
334 seconds % 60
335 )
336}
337
338fn civil_from_days(days: i64) -> (i64, u32, u32) {
339 let z = days + 719_468;
340 let era = z.div_euclid(146_097);
341 let day_of_era = z.rem_euclid(146_097);
342 let year_of_era =
343 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
344 let year = year_of_era + era * 400;
345 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
346 let mp = (5 * day_of_year + 2) / 153;
347 let day = (day_of_year - (153 * mp + 2) / 5 + 1) as u32;
348 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
349 (year + i64::from(month <= 2), month, day)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use rustlavel_client::{Fake, FakeResponse};
356
357 fn config() -> S3Config {
358 S3Config {
359 bucket: "uploads".into(),
360 region: "eu-west-1".into(),
361 access_key: "AKIAEXAMPLE".into(),
362 secret_key: "secret".into(),
363 ..S3Config::default()
364 }
365 }
366
367 #[test]
368 fn addresses_aws_in_virtual_host_style_by_default() {
369 let storage = S3Storage::new(config());
370
371 assert_eq!(storage.config.host(), "uploads.s3.eu-west-1.amazonaws.com");
372 assert_eq!(storage.config.path_for("a/b.png"), "/a/b.png");
373 assert_eq!(
374 storage.url("a/b.png").as_deref(),
375 Some("https://uploads.s3.eu-west-1.amazonaws.com/a/b.png")
376 );
377 }
378
379 #[test]
380 fn a_custom_endpoint_switches_to_path_style() {
381 let storage = S3Storage::new(S3Config {
382 endpoint: Some("http://127.0.0.1:9000".into()),
383 ..config()
384 });
385
386 assert_eq!(storage.config.host(), "127.0.0.1:9000");
387 assert_eq!(storage.config.path_for("a.png"), "/uploads/a.png");
388 assert_eq!(storage.url("a.png").as_deref(), Some("http://127.0.0.1:9000/uploads/a.png"));
389 }
390
391 #[test]
392 fn a_public_url_overrides_the_api_host() {
393 let storage = S3Storage::new(S3Config {
394 public_url: Some("https://cdn.example.com".into()),
395 ..config()
396 });
397
398 assert_eq!(storage.url("a/b.png").as_deref(), Some("https://cdn.example.com/a/b.png"));
399 }
400
401 #[tokio::test]
402 async fn a_put_is_signed_and_typed() {
403 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("")));
404 let storage = S3Storage::with_client(config(), client);
405
406 storage.put("photos/holiday.png", b"bytes".to_vec()).await.unwrap();
407
408 let fake = storage.client.fake().unwrap();
409 let sent = &fake.recorded()[0];
410
411 assert_eq!(sent.url, "https://uploads.s3.eu-west-1.amazonaws.com/photos/holiday.png");
412 assert_eq!(sent.headers.get("content-type"), Some("image/png"));
413 assert!(sent.headers.get("authorization").unwrap().starts_with("AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/"));
414 assert!(sent.headers.contains("x-amz-content-sha256"));
415 assert_eq!(sent.headers.get("x-amz-acl"), None, "private by default");
416 }
417
418 #[tokio::test]
419 async fn a_public_put_asks_for_a_public_acl() {
420 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text("")));
421 let storage = S3Storage::with_client(config(), client);
422
423 storage.put_with("a.txt", b"x".to_vec(), Visibility::Public).await.unwrap();
424
425 assert_eq!(
426 storage.client.fake().unwrap().recorded()[0].headers.get("x-amz-acl"),
427 Some("public-read")
428 );
429 }
430
431 #[tokio::test]
432 async fn a_missing_object_names_the_bucket() {
433 let client =
434 Client::new().faking(Fake::new().fallback(FakeResponse::text("no").status(404)));
435 let storage = S3Storage::with_client(config(), client);
436
437 let error = storage.get("gone.txt").await.unwrap_err().to_string();
438
439 assert!(error.contains("gone.txt"));
440 assert!(error.contains("uploads"));
441 }
442
443 #[tokio::test]
444 async fn deleting_something_absent_is_not_an_error() {
445 let client =
446 Client::new().faking(Fake::new().fallback(FakeResponse::text("no").status(404)));
447 let storage = S3Storage::with_client(config(), client);
448
449 assert!(storage.delete("gone.txt").await.is_ok());
450 }
451
452 #[tokio::test]
453 async fn a_listing_is_parsed_out_of_the_xml() {
454 let xml = r#"<?xml version="1.0"?>
455<ListBucketResult>
456 <Contents><Key>photos/a.png</Key><Size>1024</Size></Contents>
457 <Contents><Key>photos/b & c.png</Key><Size>2048</Size></Contents>
458 <CommonPrefixes><Prefix>photos/thumbs/</Prefix></CommonPrefixes>
459</ListBucketResult>"#;
460
461 let client = Client::new().faking(Fake::new().fallback(FakeResponse::text(xml)));
462 let storage = S3Storage::with_client(config(), client);
463
464 let entries = storage.list("photos").await.unwrap();
465
466 assert_eq!(entries.len(), 3);
467 assert_eq!(entries[0].path, "photos/a.png");
468 assert_eq!(entries[0].size, 1024);
469 assert_eq!(entries[1].path, "photos/b & c.png");
470 assert!(entries[2].is_directory);
471 }
472
473 #[tokio::test]
474 async fn the_secret_key_never_reaches_a_request_or_an_error() {
475 let client =
476 Client::new().faking(Fake::new().fallback(FakeResponse::text("denied").status(403)));
477 let storage = S3Storage::with_client(config(), client);
478
479 let error = storage.get("a.txt").await.unwrap_err().to_string();
480 assert!(!error.contains("secret"));
481
482 let sent = &storage.client.fake().unwrap().recorded()[0];
483 for (_, value) in sent.headers.iter() {
484 assert!(!value.contains("secret"), "the secret key leaked into {value}");
485 }
486 }
487
488 #[test]
489 fn a_bucketless_configuration_says_what_to_set() {
490 let config = rustlavel_core::Config::new();
491 config.set("storage.driver", "s3");
492
493 let error = S3Config::from_config(&config).unwrap_err().to_string();
494 assert!(error.contains("storage.bucket"));
495 }
496
497 #[test]
498 fn timestamps_are_in_the_format_sigv4_requires() {
499 let stamp = timestamp();
500
501 assert_eq!(stamp.len(), 16);
502 assert!(stamp.ends_with('Z'));
503 assert_eq!(&stamp[8..9], "T");
504 }
505}