1use std::time::Duration;
2
3use axum::body::Bytes;
4use futures_util::Stream;
5use rusty_s3::actions::{DeleteObject, GetObject, HeadObject, ListObjectsV2, PutObject, S3Action};
6use rusty_s3::{Bucket, Credentials, UrlStyle};
7
8use base64::Engine;
9
10use crate::error::Error;
11use crate::namespace::Namespace;
12use crate::storage::Reclaimed;
13
14const CHECKSUM: &str = "x-amz-checksum-sha256";
15const COPY_SOURCE: &str = "x-amz-copy-source";
16
17struct Entry {
19 key: String,
20 last_modified: String,
21 size: u64,
22}
23
24impl Entry {
25 fn age(&self) -> Option<Duration> {
29 let written = time::OffsetDateTime::parse(
30 &self.last_modified,
31 &time::format_description::well_known::Rfc3339,
32 )
33 .ok()?;
34
35 Duration::try_from(time::OffsetDateTime::now_utc() - written).ok()
36 }
37}
38
39pub struct Presigned {
42 pub href: String,
43 pub headers: Vec<(String, String)>,
44}
45
46async fn read_retrying(request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
61 let retry = request.try_clone();
62
63 match request.send().await {
64 Ok(response) => Ok(response),
65 Err(_) => match retry {
66 Some(retry) => retry.send().await.map_err(|_| unreachable_store()),
67 None => Err(unreachable_store()),
68 },
69 }
70}
71
72fn unreachable_store() -> Error {
73 Error::Storage(std::io::Error::other("the object store is unreachable"))
74}
75
76#[derive(Clone)]
77pub struct S3Store {
78 bucket: Bucket,
79 credentials: Credentials,
80 client: reqwest::Client,
81 lifetime: Duration,
82 redirect: bool,
83}
84
85pub struct S3Config {
86 pub endpoint: String,
87 pub bucket: String,
88 pub region: String,
89 pub access_key: String,
90 pub secret_key: String,
91 pub path_style: bool,
92 pub redirect: bool,
93 pub lifetime: Duration,
98}
99
100impl S3Store {
101 pub fn new(config: &S3Config) -> Result<Self, Error> {
102 crate::tls::install_crypto_provider();
103
104 let style = if config.path_style {
105 UrlStyle::Path
106 } else {
107 UrlStyle::VirtualHost
108 };
109
110 let bucket = Bucket::new(
111 config
112 .endpoint
113 .parse()
114 .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
115 style,
116 config.bucket.clone(),
117 config.region.clone(),
118 )
119 .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
120
121 Ok(Self {
122 bucket,
123 credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
124 client: reqwest::Client::new(),
125 lifetime: config.lifetime,
126 redirect: config.redirect,
127 })
128 }
129
130 fn content_key(oid: &str) -> String {
131 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
132 }
133
134 fn incoming_key(ns: &Namespace, oid: &str) -> String {
141 format!(
142 ".incoming/{}/{}/{}/{}/{oid}",
143 ns.org(),
144 ns.repo(),
145 &oid[0..2],
146 &oid[2..4]
147 )
148 }
149
150 fn marker_key(ns: &Namespace, oid: &str) -> String {
151 format!(
152 "{}/{}/{}/{}/{oid}",
153 ns.org(),
154 ns.repo(),
155 &oid[0..2],
156 &oid[2..4]
157 )
158 }
159
160 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
161 if crate::storage::LocalStore::validate_oid(oid).is_err() {
162 return false;
163 }
164
165 self.head(&Self::marker_key(ns, oid)).await.is_ok()
166 }
167
168 async fn head(&self, key: &str) -> Result<u64, Error> {
173 let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
174 let url = action.sign(self.lifetime);
175
176 let response = read_retrying(self.client.head(url)).await?;
177
178 if !response.status().is_success() {
179 return Err(Error::NotFound);
180 }
181
182 response
186 .headers()
187 .get(reqwest::header::CONTENT_LENGTH)
188 .and_then(|value| value.to_str().ok())
189 .and_then(|value| value.parse().ok())
190 .ok_or_else(|| {
191 Error::Storage(std::io::Error::other(
192 "the object store gave no object size",
193 ))
194 })
195 }
196
197 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
198 crate::storage::LocalStore::validate_oid(oid)?;
203
204 self.head(&Self::content_key(oid)).await
205 }
206
207 pub async fn read(
213 &self,
214 oid: &str,
215 start: u64,
216 length: u64,
217 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
218 crate::storage::LocalStore::validate_oid(oid)?;
219
220 let key = Self::content_key(oid);
221 let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
222 let url = action.sign(self.lifetime);
223
224 let response = self
225 .client
226 .get(url)
227 .header(
228 reqwest::header::RANGE,
229 format!("bytes={start}-{}", start + length.saturating_sub(1)),
230 )
231 .send()
232 .await
233 .map_err(|_| {
234 Error::Storage(std::io::Error::other("the object store is unreachable"))
235 })?;
236
237 if !response.status().is_success() {
238 return Err(Error::NotFound);
239 }
240
241 Ok(response.bytes_stream())
242 }
243
244 pub fn presigned_download(&self, oid: &str) -> Option<String> {
250 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
251 return None;
252 }
253
254 let key = Self::content_key(oid);
255
256 Some(
257 GetObject::new(&self.bucket, Some(&self.credentials), &key)
258 .sign(self.lifetime)
259 .to_string(),
260 )
261 }
262
263 pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<Presigned> {
269 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
270 return None;
271 }
272
273 let digest = base64::engine::general_purpose::STANDARD.encode(hex::decode(oid).ok()?);
274 let key = Self::incoming_key(ns, oid);
275 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), &key);
276 action
277 .headers_mut()
278 .insert(CHECKSUM, std::borrow::Cow::Owned(digest.clone()));
279
280 Some(Presigned {
281 href: action.sign(self.lifetime).to_string(),
282 headers: vec![(CHECKSUM.to_owned(), digest)],
283 })
284 }
285
286 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<u64, Error> {
289 crate::storage::LocalStore::validate_oid(oid)?;
290
291 self.head(&Self::incoming_key(ns, oid)).await
292 }
293
294 pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
298 crate::storage::LocalStore::validate_oid(oid)?;
299
300 let incoming = Self::incoming_key(ns, oid);
301 let content = Self::content_key(oid);
302
303 if self.head(&content).await.is_err() {
306 self.copy(&incoming, &content).await?;
307 }
308
309 self.put(
310 &Self::marker_key(ns, oid),
311 reqwest::Body::from(Vec::new()),
312 0,
313 )
314 .await?;
315
316 if let Err(error) = self.delete(&incoming).await {
320 tracing::warn!(%error, key = incoming, "an adopted upload could not be cleaned up");
321 }
322
323 Ok(())
324 }
325
326 async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
330 let source = format!("/{}/{from}", self.bucket.name());
331 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), to);
332 action
333 .headers_mut()
334 .insert(COPY_SOURCE, std::borrow::Cow::Owned(source.clone()));
335
336 let response = self
337 .client
338 .put(action.sign(self.lifetime))
339 .header(COPY_SOURCE, source)
340 .header(reqwest::header::CONTENT_LENGTH, 0)
341 .send()
342 .await
343 .map_err(|_| unreachable_store())?;
344
345 self.expect_success(response, "copy").await?;
346
347 Ok(())
348 }
349
350 async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
351 let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
352 let url = action.sign(self.lifetime);
353
354 let response = self
355 .client
356 .put(url)
357 .header(reqwest::header::CONTENT_LENGTH, length)
361 .body(body)
362 .send()
363 .await
364 .map_err(|_| {
365 Error::Storage(std::io::Error::other("the object store is unreachable"))
366 })?;
367
368 let status = response.status();
369 if !status.is_success() {
370 let detail = response.text().await.unwrap_or_default();
375
376 return Err(Error::Storage(std::io::Error::other(format!(
377 "the object store refused a write with {status}: {}",
378 detail.trim()
379 ))));
380 }
381
382 Ok(())
383 }
384
385 pub async fn store(
395 &self,
396 ns: &Namespace,
397 oid: &str,
398 staged: &std::path::Path,
399 ) -> Result<(), Error> {
400 crate::storage::LocalStore::validate_oid(oid)?;
401
402 if self.head(&Self::content_key(oid)).await.is_err() {
403 let file = tokio::fs::File::open(staged).await?;
404 let length = file.metadata().await?.len();
405 let stream = tokio_util::io::ReaderStream::new(file);
406
407 self.put(
408 &Self::content_key(oid),
409 reqwest::Body::wrap_stream(stream),
410 length,
411 )
412 .await?;
413 }
414
415 self.put(
416 &Self::marker_key(ns, oid),
417 reqwest::Body::from(Vec::new()),
418 0,
419 )
420 .await
421 }
422
423 pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
436 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
437 action.headers_mut().insert("if-none-match", "*");
438 let url = action.sign(self.lifetime);
439
440 let length = body.len();
441 let response = self
442 .client
443 .put(url)
444 .header("if-none-match", "*")
445 .header(reqwest::header::CONTENT_LENGTH, length)
446 .body(body)
447 .send()
448 .await
449 .map_err(|_| unreachable_store())?;
450
451 if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
452 return Ok(false);
453 }
454
455 self.expect_success(response, "write").await?;
456
457 Ok(true)
458 }
459
460 pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
461 let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
462 let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
463
464 if response.status() == reqwest::StatusCode::NOT_FOUND {
465 return Ok(None);
466 }
467
468 let response = self.expect_success(response, "read").await?;
469
470 response
471 .bytes()
472 .await
473 .map(|bytes| Some(bytes.to_vec()))
474 .map_err(|_| unreachable_store())
475 }
476
477 pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
478 let existed = self.head(key).await.is_ok();
481
482 let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
483 let response = self
484 .client
485 .delete(action.sign(self.lifetime))
486 .send()
487 .await
488 .map_err(|_| unreachable_store())?;
489
490 self.expect_success(response, "delete").await?;
491
492 Ok(existed)
493 }
494
495 pub async fn reclaim_incoming(&self, older_than: Duration) -> Result<Reclaimed, Error> {
501 let mut reclaimed = Reclaimed::default();
502
503 for entry in self.entries(".incoming/").await? {
504 if entry.age().is_none_or(|age| age < older_than) {
506 continue;
507 }
508
509 if self.delete(&entry.key).await.is_ok() {
510 reclaimed.files += 1;
511 reclaimed.bytes += entry.size;
512 }
513 }
514
515 Ok(reclaimed)
516 }
517
518 pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
523 Ok(self
524 .entries(prefix)
525 .await?
526 .into_iter()
527 .map(|entry| entry.key)
528 .collect())
529 }
530
531 async fn entries(&self, prefix: &str) -> Result<Vec<Entry>, Error> {
532 let mut out = Vec::new();
533 let mut token: Option<String> = None;
534
535 loop {
536 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
537 action.with_prefix(prefix);
538 if let Some(token) = &token {
539 action.with_continuation_token(token);
540 }
541
542 let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
543 let body = self
544 .expect_success(response, "list")
545 .await?
546 .text()
547 .await
548 .map_err(|_| unreachable_store())?;
549
550 let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
551 Error::Storage(std::io::Error::other(format!(
552 "the object store sent a listing this server could not read: {error}"
553 )))
554 })?;
555
556 out.extend(listing.contents.into_iter().map(|object| Entry {
557 key: object.key,
558 last_modified: object.last_modified,
559 size: object.size,
560 }));
561
562 match listing.next_continuation_token {
563 Some(next) => token = Some(next),
564 None => break,
565 }
566 }
567
568 Ok(out)
569 }
570
571 async fn expect_success(
572 &self,
573 response: reqwest::Response,
574 what: &str,
575 ) -> Result<reqwest::Response, Error> {
576 let status = response.status();
577 if status.is_success() {
578 return Ok(response);
579 }
580
581 let detail = response.text().await.unwrap_or_default();
582
583 Err(Error::Storage(std::io::Error::other(format!(
584 "the object store refused a {what} with {status}: {}",
585 detail.trim()
586 ))))
587 }
588
589 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
594 let prefix = format!("{}/{}/", ns.org(), ns.repo());
595 let mut objects = 0;
596 let mut bytes = 0;
597
598 for oid in self.list(&prefix).await {
599 objects += 1;
600 bytes += self.size_of(&oid).await.unwrap_or_default();
601 }
602
603 (objects, bytes)
604 }
605
606 async fn list(&self, prefix: &str) -> Vec<String> {
607 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
608 action.with_prefix(prefix);
609
610 let response = match self.client.get(action.sign(self.lifetime)).send().await {
614 Ok(response) => response,
615 Err(error) => {
616 tracing::warn!(%error, "the object store could not be listed");
617 return Vec::new();
618 }
619 };
620
621 let body = match response.text().await {
622 Ok(body) => body,
623 Err(error) => {
624 tracing::warn!(%error, "the listing could not be read");
625 return Vec::new();
626 }
627 };
628
629 let listing = match ListObjectsV2::parse_response(&body) {
630 Ok(listing) => listing,
631 Err(error) => {
632 tracing::warn!(%error, "the listing could not be parsed");
633 return Vec::new();
634 }
635 };
636
637 listing
638 .contents
639 .into_iter()
640 .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
641 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
642 .collect()
643 }
644}
645
646#[cfg(test)]
647pub(crate) mod tests;