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 crate::error::Error;
9use crate::namespace::Namespace;
10
11async fn read_retrying(request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
26 let retry = request.try_clone();
27
28 match request.send().await {
29 Ok(response) => Ok(response),
30 Err(_) => match retry {
31 Some(retry) => retry.send().await.map_err(|_| unreachable_store()),
32 None => Err(unreachable_store()),
33 },
34 }
35}
36
37fn unreachable_store() -> Error {
38 Error::Storage(std::io::Error::other("the object store is unreachable"))
39}
40
41#[derive(Clone)]
42pub struct S3Store {
43 bucket: Bucket,
44 credentials: Credentials,
45 client: reqwest::Client,
46 lifetime: Duration,
47 redirect: bool,
48}
49
50pub struct S3Config {
51 pub endpoint: String,
52 pub bucket: String,
53 pub region: String,
54 pub access_key: String,
55 pub secret_key: String,
56 pub path_style: bool,
57 pub redirect: bool,
58 pub lifetime: Duration,
63}
64
65impl S3Store {
66 pub fn new(config: &S3Config) -> Result<Self, Error> {
67 crate::tls::install_crypto_provider();
68
69 let style = if config.path_style {
70 UrlStyle::Path
71 } else {
72 UrlStyle::VirtualHost
73 };
74
75 let bucket = Bucket::new(
76 config
77 .endpoint
78 .parse()
79 .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
80 style,
81 config.bucket.clone(),
82 config.region.clone(),
83 )
84 .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
85
86 Ok(Self {
87 bucket,
88 credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
89 client: reqwest::Client::new(),
90 lifetime: config.lifetime,
91 redirect: config.redirect,
92 })
93 }
94
95 fn content_key(oid: &str) -> String {
96 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
97 }
98
99 fn marker_key(ns: &Namespace, oid: &str) -> String {
100 format!(
101 "{}/{}/{}/{}/{oid}",
102 ns.org(),
103 ns.repo(),
104 &oid[0..2],
105 &oid[2..4]
106 )
107 }
108
109 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
110 if crate::storage::LocalStore::validate_oid(oid).is_err() {
111 return false;
112 }
113
114 self.head(&Self::marker_key(ns, oid)).await.is_ok()
115 }
116
117 async fn head(&self, key: &str) -> Result<u64, Error> {
122 let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
123 let url = action.sign(self.lifetime);
124
125 let response = read_retrying(self.client.head(url)).await?;
126
127 if !response.status().is_success() {
128 return Err(Error::NotFound);
129 }
130
131 response
135 .headers()
136 .get(reqwest::header::CONTENT_LENGTH)
137 .and_then(|value| value.to_str().ok())
138 .and_then(|value| value.parse().ok())
139 .ok_or_else(|| {
140 Error::Storage(std::io::Error::other(
141 "the object store gave no object size",
142 ))
143 })
144 }
145
146 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
147 crate::storage::LocalStore::validate_oid(oid)?;
152
153 self.head(&Self::content_key(oid)).await
154 }
155
156 pub async fn read(
162 &self,
163 oid: &str,
164 start: u64,
165 length: u64,
166 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
167 crate::storage::LocalStore::validate_oid(oid)?;
168
169 let key = Self::content_key(oid);
170 let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
171 let url = action.sign(self.lifetime);
172
173 let response = self
174 .client
175 .get(url)
176 .header(
177 reqwest::header::RANGE,
178 format!("bytes={start}-{}", start + length.saturating_sub(1)),
179 )
180 .send()
181 .await
182 .map_err(|_| {
183 Error::Storage(std::io::Error::other("the object store is unreachable"))
184 })?;
185
186 if !response.status().is_success() {
187 return Err(Error::NotFound);
188 }
189
190 Ok(response.bytes_stream())
191 }
192
193 pub fn presigned_download(&self, oid: &str) -> Option<String> {
199 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
200 return None;
201 }
202
203 let key = Self::content_key(oid);
204
205 Some(
206 GetObject::new(&self.bucket, Some(&self.credentials), &key)
207 .sign(self.lifetime)
208 .to_string(),
209 )
210 }
211
212 async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
213 let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
214 let url = action.sign(self.lifetime);
215
216 let response = self
217 .client
218 .put(url)
219 .header(reqwest::header::CONTENT_LENGTH, length)
223 .body(body)
224 .send()
225 .await
226 .map_err(|_| {
227 Error::Storage(std::io::Error::other("the object store is unreachable"))
228 })?;
229
230 let status = response.status();
231 if !status.is_success() {
232 let detail = response.text().await.unwrap_or_default();
237
238 return Err(Error::Storage(std::io::Error::other(format!(
239 "the object store refused a write with {status}: {}",
240 detail.trim()
241 ))));
242 }
243
244 Ok(())
245 }
246
247 pub async fn store(
257 &self,
258 ns: &Namespace,
259 oid: &str,
260 staged: &std::path::Path,
261 ) -> Result<(), Error> {
262 crate::storage::LocalStore::validate_oid(oid)?;
263
264 if self.head(&Self::content_key(oid)).await.is_err() {
265 let file = tokio::fs::File::open(staged).await?;
266 let length = file.metadata().await?.len();
267 let stream = tokio_util::io::ReaderStream::new(file);
268
269 self.put(
270 &Self::content_key(oid),
271 reqwest::Body::wrap_stream(stream),
272 length,
273 )
274 .await?;
275 }
276
277 self.put(
278 &Self::marker_key(ns, oid),
279 reqwest::Body::from(Vec::new()),
280 0,
281 )
282 .await
283 }
284
285 pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
298 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
299 action.headers_mut().insert("if-none-match", "*");
300 let url = action.sign(self.lifetime);
301
302 let length = body.len();
303 let response = self
304 .client
305 .put(url)
306 .header("if-none-match", "*")
307 .header(reqwest::header::CONTENT_LENGTH, length)
308 .body(body)
309 .send()
310 .await
311 .map_err(|_| unreachable_store())?;
312
313 if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
314 return Ok(false);
315 }
316
317 self.expect_success(response, "write").await?;
318
319 Ok(true)
320 }
321
322 pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
323 let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
324 let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
325
326 if response.status() == reqwest::StatusCode::NOT_FOUND {
327 return Ok(None);
328 }
329
330 let response = self.expect_success(response, "read").await?;
331
332 response
333 .bytes()
334 .await
335 .map(|bytes| Some(bytes.to_vec()))
336 .map_err(|_| unreachable_store())
337 }
338
339 pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
340 let existed = self.head(key).await.is_ok();
343
344 let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
345 let response = self
346 .client
347 .delete(action.sign(self.lifetime))
348 .send()
349 .await
350 .map_err(|_| unreachable_store())?;
351
352 self.expect_success(response, "delete").await?;
353
354 Ok(existed)
355 }
356
357 pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
362 let mut out = Vec::new();
363 let mut token: Option<String> = None;
364
365 loop {
366 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
367 action.with_prefix(prefix);
368 if let Some(token) = &token {
369 action.with_continuation_token(token);
370 }
371
372 let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
373 let body = self
374 .expect_success(response, "list")
375 .await?
376 .text()
377 .await
378 .map_err(|_| unreachable_store())?;
379
380 let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
381 Error::Storage(std::io::Error::other(format!(
382 "the object store sent a listing this server could not read: {error}"
383 )))
384 })?;
385
386 out.extend(listing.contents.into_iter().map(|object| object.key));
387
388 match listing.next_continuation_token {
389 Some(next) => token = Some(next),
390 None => break,
391 }
392 }
393
394 Ok(out)
395 }
396
397 async fn expect_success(
398 &self,
399 response: reqwest::Response,
400 what: &str,
401 ) -> Result<reqwest::Response, Error> {
402 let status = response.status();
403 if status.is_success() {
404 return Ok(response);
405 }
406
407 let detail = response.text().await.unwrap_or_default();
408
409 Err(Error::Storage(std::io::Error::other(format!(
410 "the object store refused a {what} with {status}: {}",
411 detail.trim()
412 ))))
413 }
414
415 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
420 let prefix = format!("{}/{}/", ns.org(), ns.repo());
421 let mut objects = 0;
422 let mut bytes = 0;
423
424 for oid in self.list(&prefix).await {
425 objects += 1;
426 bytes += self.size_of(&oid).await.unwrap_or_default();
427 }
428
429 (objects, bytes)
430 }
431
432 async fn list(&self, prefix: &str) -> Vec<String> {
433 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
434 action.with_prefix(prefix);
435
436 let response = match self.client.get(action.sign(self.lifetime)).send().await {
440 Ok(response) => response,
441 Err(error) => {
442 tracing::warn!(%error, "the object store could not be listed");
443 return Vec::new();
444 }
445 };
446
447 let body = match response.text().await {
448 Ok(body) => body,
449 Err(error) => {
450 tracing::warn!(%error, "the listing could not be read");
451 return Vec::new();
452 }
453 };
454
455 let listing = match ListObjectsV2::parse_response(&body) {
456 Ok(listing) => listing,
457 Err(error) => {
458 tracing::warn!(%error, "the listing could not be parsed");
459 return Vec::new();
460 }
461 };
462
463 listing
464 .contents
465 .into_iter()
466 .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
467 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
468 .collect()
469 }
470}
471
472#[cfg(test)]
473pub(crate) mod tests;