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;
12
13const CHECKSUM: &str = "x-amz-checksum-sha256";
14const COPY_SOURCE: &str = "x-amz-copy-source";
15
16pub struct Presigned {
19 pub href: String,
20 pub headers: Vec<(String, String)>,
21}
22
23async fn read_retrying(request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
38 let retry = request.try_clone();
39
40 match request.send().await {
41 Ok(response) => Ok(response),
42 Err(_) => match retry {
43 Some(retry) => retry.send().await.map_err(|_| unreachable_store()),
44 None => Err(unreachable_store()),
45 },
46 }
47}
48
49fn unreachable_store() -> Error {
50 Error::Storage(std::io::Error::other("the object store is unreachable"))
51}
52
53#[derive(Clone)]
54pub struct S3Store {
55 bucket: Bucket,
56 credentials: Credentials,
57 client: reqwest::Client,
58 lifetime: Duration,
59 redirect: bool,
60}
61
62pub struct S3Config {
63 pub endpoint: String,
64 pub bucket: String,
65 pub region: String,
66 pub access_key: String,
67 pub secret_key: String,
68 pub path_style: bool,
69 pub redirect: bool,
70 pub lifetime: Duration,
75}
76
77impl S3Store {
78 pub fn new(config: &S3Config) -> Result<Self, Error> {
79 crate::tls::install_crypto_provider();
80
81 let style = if config.path_style {
82 UrlStyle::Path
83 } else {
84 UrlStyle::VirtualHost
85 };
86
87 let bucket = Bucket::new(
88 config
89 .endpoint
90 .parse()
91 .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
92 style,
93 config.bucket.clone(),
94 config.region.clone(),
95 )
96 .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
97
98 Ok(Self {
99 bucket,
100 credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
101 client: reqwest::Client::new(),
102 lifetime: config.lifetime,
103 redirect: config.redirect,
104 })
105 }
106
107 fn content_key(oid: &str) -> String {
108 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
109 }
110
111 fn incoming_key(ns: &Namespace, oid: &str) -> String {
118 format!(
119 ".incoming/{}/{}/{}/{}/{oid}",
120 ns.org(),
121 ns.repo(),
122 &oid[0..2],
123 &oid[2..4]
124 )
125 }
126
127 fn marker_key(ns: &Namespace, oid: &str) -> String {
128 format!(
129 "{}/{}/{}/{}/{oid}",
130 ns.org(),
131 ns.repo(),
132 &oid[0..2],
133 &oid[2..4]
134 )
135 }
136
137 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
138 if crate::storage::LocalStore::validate_oid(oid).is_err() {
139 return false;
140 }
141
142 self.head(&Self::marker_key(ns, oid)).await.is_ok()
143 }
144
145 async fn head(&self, key: &str) -> Result<u64, Error> {
150 let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
151 let url = action.sign(self.lifetime);
152
153 let response = read_retrying(self.client.head(url)).await?;
154
155 if !response.status().is_success() {
156 return Err(Error::NotFound);
157 }
158
159 response
163 .headers()
164 .get(reqwest::header::CONTENT_LENGTH)
165 .and_then(|value| value.to_str().ok())
166 .and_then(|value| value.parse().ok())
167 .ok_or_else(|| {
168 Error::Storage(std::io::Error::other(
169 "the object store gave no object size",
170 ))
171 })
172 }
173
174 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
175 crate::storage::LocalStore::validate_oid(oid)?;
180
181 self.head(&Self::content_key(oid)).await
182 }
183
184 pub async fn read(
190 &self,
191 oid: &str,
192 start: u64,
193 length: u64,
194 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
195 crate::storage::LocalStore::validate_oid(oid)?;
196
197 let key = Self::content_key(oid);
198 let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
199 let url = action.sign(self.lifetime);
200
201 let response = self
202 .client
203 .get(url)
204 .header(
205 reqwest::header::RANGE,
206 format!("bytes={start}-{}", start + length.saturating_sub(1)),
207 )
208 .send()
209 .await
210 .map_err(|_| {
211 Error::Storage(std::io::Error::other("the object store is unreachable"))
212 })?;
213
214 if !response.status().is_success() {
215 return Err(Error::NotFound);
216 }
217
218 Ok(response.bytes_stream())
219 }
220
221 pub fn presigned_download(&self, oid: &str) -> Option<String> {
227 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
228 return None;
229 }
230
231 let key = Self::content_key(oid);
232
233 Some(
234 GetObject::new(&self.bucket, Some(&self.credentials), &key)
235 .sign(self.lifetime)
236 .to_string(),
237 )
238 }
239
240 pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<Presigned> {
246 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
247 return None;
248 }
249
250 let digest = base64::engine::general_purpose::STANDARD.encode(hex::decode(oid).ok()?);
251 let key = Self::incoming_key(ns, oid);
252 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), &key);
253 action
254 .headers_mut()
255 .insert(CHECKSUM, std::borrow::Cow::Owned(digest.clone()));
256
257 Some(Presigned {
258 href: action.sign(self.lifetime).to_string(),
259 headers: vec![(CHECKSUM.to_owned(), digest)],
260 })
261 }
262
263 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<u64, Error> {
266 crate::storage::LocalStore::validate_oid(oid)?;
267
268 self.head(&Self::incoming_key(ns, oid)).await
269 }
270
271 pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
275 crate::storage::LocalStore::validate_oid(oid)?;
276
277 let incoming = Self::incoming_key(ns, oid);
278 let content = Self::content_key(oid);
279
280 if self.head(&content).await.is_err() {
283 self.copy(&incoming, &content).await?;
284 }
285
286 self.put(
287 &Self::marker_key(ns, oid),
288 reqwest::Body::from(Vec::new()),
289 0,
290 )
291 .await?;
292
293 if let Err(error) = self.delete(&incoming).await {
297 tracing::warn!(%error, key = incoming, "an adopted upload could not be cleaned up");
298 }
299
300 Ok(())
301 }
302
303 async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
307 let source = format!("/{}/{from}", self.bucket.name());
308 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), to);
309 action
310 .headers_mut()
311 .insert(COPY_SOURCE, std::borrow::Cow::Owned(source.clone()));
312
313 let response = self
314 .client
315 .put(action.sign(self.lifetime))
316 .header(COPY_SOURCE, source)
317 .header(reqwest::header::CONTENT_LENGTH, 0)
318 .send()
319 .await
320 .map_err(|_| unreachable_store())?;
321
322 self.expect_success(response, "copy").await?;
323
324 Ok(())
325 }
326
327 async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
328 let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
329 let url = action.sign(self.lifetime);
330
331 let response = self
332 .client
333 .put(url)
334 .header(reqwest::header::CONTENT_LENGTH, length)
338 .body(body)
339 .send()
340 .await
341 .map_err(|_| {
342 Error::Storage(std::io::Error::other("the object store is unreachable"))
343 })?;
344
345 let status = response.status();
346 if !status.is_success() {
347 let detail = response.text().await.unwrap_or_default();
352
353 return Err(Error::Storage(std::io::Error::other(format!(
354 "the object store refused a write with {status}: {}",
355 detail.trim()
356 ))));
357 }
358
359 Ok(())
360 }
361
362 pub async fn store(
372 &self,
373 ns: &Namespace,
374 oid: &str,
375 staged: &std::path::Path,
376 ) -> Result<(), Error> {
377 crate::storage::LocalStore::validate_oid(oid)?;
378
379 if self.head(&Self::content_key(oid)).await.is_err() {
380 let file = tokio::fs::File::open(staged).await?;
381 let length = file.metadata().await?.len();
382 let stream = tokio_util::io::ReaderStream::new(file);
383
384 self.put(
385 &Self::content_key(oid),
386 reqwest::Body::wrap_stream(stream),
387 length,
388 )
389 .await?;
390 }
391
392 self.put(
393 &Self::marker_key(ns, oid),
394 reqwest::Body::from(Vec::new()),
395 0,
396 )
397 .await
398 }
399
400 pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
413 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
414 action.headers_mut().insert("if-none-match", "*");
415 let url = action.sign(self.lifetime);
416
417 let length = body.len();
418 let response = self
419 .client
420 .put(url)
421 .header("if-none-match", "*")
422 .header(reqwest::header::CONTENT_LENGTH, length)
423 .body(body)
424 .send()
425 .await
426 .map_err(|_| unreachable_store())?;
427
428 if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
429 return Ok(false);
430 }
431
432 self.expect_success(response, "write").await?;
433
434 Ok(true)
435 }
436
437 pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
438 let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
439 let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
440
441 if response.status() == reqwest::StatusCode::NOT_FOUND {
442 return Ok(None);
443 }
444
445 let response = self.expect_success(response, "read").await?;
446
447 response
448 .bytes()
449 .await
450 .map(|bytes| Some(bytes.to_vec()))
451 .map_err(|_| unreachable_store())
452 }
453
454 pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
455 let existed = self.head(key).await.is_ok();
458
459 let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
460 let response = self
461 .client
462 .delete(action.sign(self.lifetime))
463 .send()
464 .await
465 .map_err(|_| unreachable_store())?;
466
467 self.expect_success(response, "delete").await?;
468
469 Ok(existed)
470 }
471
472 pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
477 let mut out = Vec::new();
478 let mut token: Option<String> = None;
479
480 loop {
481 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
482 action.with_prefix(prefix);
483 if let Some(token) = &token {
484 action.with_continuation_token(token);
485 }
486
487 let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
488 let body = self
489 .expect_success(response, "list")
490 .await?
491 .text()
492 .await
493 .map_err(|_| unreachable_store())?;
494
495 let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
496 Error::Storage(std::io::Error::other(format!(
497 "the object store sent a listing this server could not read: {error}"
498 )))
499 })?;
500
501 out.extend(listing.contents.into_iter().map(|object| object.key));
502
503 match listing.next_continuation_token {
504 Some(next) => token = Some(next),
505 None => break,
506 }
507 }
508
509 Ok(out)
510 }
511
512 async fn expect_success(
513 &self,
514 response: reqwest::Response,
515 what: &str,
516 ) -> Result<reqwest::Response, Error> {
517 let status = response.status();
518 if status.is_success() {
519 return Ok(response);
520 }
521
522 let detail = response.text().await.unwrap_or_default();
523
524 Err(Error::Storage(std::io::Error::other(format!(
525 "the object store refused a {what} with {status}: {}",
526 detail.trim()
527 ))))
528 }
529
530 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
535 let prefix = format!("{}/{}/", ns.org(), ns.repo());
536 let mut objects = 0;
537 let mut bytes = 0;
538
539 for oid in self.list(&prefix).await {
540 objects += 1;
541 bytes += self.size_of(&oid).await.unwrap_or_default();
542 }
543
544 (objects, bytes)
545 }
546
547 async fn list(&self, prefix: &str) -> Vec<String> {
548 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
549 action.with_prefix(prefix);
550
551 let response = match self.client.get(action.sign(self.lifetime)).send().await {
555 Ok(response) => response,
556 Err(error) => {
557 tracing::warn!(%error, "the object store could not be listed");
558 return Vec::new();
559 }
560 };
561
562 let body = match response.text().await {
563 Ok(body) => body,
564 Err(error) => {
565 tracing::warn!(%error, "the listing could not be read");
566 return Vec::new();
567 }
568 };
569
570 let listing = match ListObjectsV2::parse_response(&body) {
571 Ok(listing) => listing,
572 Err(error) => {
573 tracing::warn!(%error, "the listing could not be parsed");
574 return Vec::new();
575 }
576 };
577
578 listing
579 .contents
580 .into_iter()
581 .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
582 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
583 .collect()
584 }
585}
586
587#[cfg(test)]
588pub(crate) mod tests;