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
11fn unreachable_store() -> Error {
18 Error::Storage(std::io::Error::other("the object store is unreachable"))
19}
20
21#[derive(Clone)]
22pub struct S3Store {
23 bucket: Bucket,
24 credentials: Credentials,
25 client: reqwest::Client,
26 lifetime: Duration,
27 redirect: bool,
28}
29
30pub struct S3Config {
31 pub endpoint: String,
32 pub bucket: String,
33 pub region: String,
34 pub access_key: String,
35 pub secret_key: String,
36 pub path_style: bool,
37 pub redirect: bool,
38 pub lifetime: Duration,
43}
44
45impl S3Store {
46 pub fn new(config: &S3Config) -> Result<Self, Error> {
47 crate::tls::install_crypto_provider();
48
49 let style = if config.path_style {
50 UrlStyle::Path
51 } else {
52 UrlStyle::VirtualHost
53 };
54
55 let bucket = Bucket::new(
56 config
57 .endpoint
58 .parse()
59 .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
60 style,
61 config.bucket.clone(),
62 config.region.clone(),
63 )
64 .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
65
66 Ok(Self {
67 bucket,
68 credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
69 client: reqwest::Client::new(),
70 lifetime: config.lifetime,
71 redirect: config.redirect,
72 })
73 }
74
75 fn content_key(oid: &str) -> String {
76 format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
77 }
78
79 fn marker_key(ns: &Namespace, oid: &str) -> String {
80 format!(
81 "{}/{}/{}/{}/{oid}",
82 ns.org(),
83 ns.repo(),
84 &oid[0..2],
85 &oid[2..4]
86 )
87 }
88
89 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
90 if crate::storage::LocalStore::validate_oid(oid).is_err() {
91 return false;
92 }
93
94 self.head(&Self::marker_key(ns, oid)).await.is_ok()
95 }
96
97 async fn head(&self, key: &str) -> Result<u64, Error> {
102 let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
103 let url = action.sign(self.lifetime);
104
105 let response = self.client.head(url).send().await.map_err(|_| {
106 Error::Storage(std::io::Error::other("the object store is unreachable"))
107 })?;
108
109 if !response.status().is_success() {
110 return Err(Error::NotFound);
111 }
112
113 response
117 .headers()
118 .get(reqwest::header::CONTENT_LENGTH)
119 .and_then(|value| value.to_str().ok())
120 .and_then(|value| value.parse().ok())
121 .ok_or_else(|| {
122 Error::Storage(std::io::Error::other(
123 "the object store gave no object size",
124 ))
125 })
126 }
127
128 pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
129 crate::storage::LocalStore::validate_oid(oid)?;
134
135 self.head(&Self::content_key(oid)).await
136 }
137
138 pub async fn read(
144 &self,
145 oid: &str,
146 start: u64,
147 length: u64,
148 ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
149 crate::storage::LocalStore::validate_oid(oid)?;
150
151 let key = Self::content_key(oid);
152 let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
153 let url = action.sign(self.lifetime);
154
155 let response = self
156 .client
157 .get(url)
158 .header(
159 reqwest::header::RANGE,
160 format!("bytes={start}-{}", start + length.saturating_sub(1)),
161 )
162 .send()
163 .await
164 .map_err(|_| {
165 Error::Storage(std::io::Error::other("the object store is unreachable"))
166 })?;
167
168 if !response.status().is_success() {
169 return Err(Error::NotFound);
170 }
171
172 Ok(response.bytes_stream())
173 }
174
175 pub fn presigned_download(&self, oid: &str) -> Option<String> {
181 if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
182 return None;
183 }
184
185 let key = Self::content_key(oid);
186
187 Some(
188 GetObject::new(&self.bucket, Some(&self.credentials), &key)
189 .sign(self.lifetime)
190 .to_string(),
191 )
192 }
193
194 async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
195 let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
196 let url = action.sign(self.lifetime);
197
198 let response = self
199 .client
200 .put(url)
201 .header(reqwest::header::CONTENT_LENGTH, length)
205 .body(body)
206 .send()
207 .await
208 .map_err(|_| {
209 Error::Storage(std::io::Error::other("the object store is unreachable"))
210 })?;
211
212 let status = response.status();
213 if !status.is_success() {
214 let detail = response.text().await.unwrap_or_default();
219
220 return Err(Error::Storage(std::io::Error::other(format!(
221 "the object store refused a write with {status}: {}",
222 detail.trim()
223 ))));
224 }
225
226 Ok(())
227 }
228
229 pub async fn store(
239 &self,
240 ns: &Namespace,
241 oid: &str,
242 staged: &std::path::Path,
243 ) -> Result<(), Error> {
244 crate::storage::LocalStore::validate_oid(oid)?;
245
246 if self.head(&Self::content_key(oid)).await.is_err() {
247 let file = tokio::fs::File::open(staged).await?;
248 let length = file.metadata().await?.len();
249 let stream = tokio_util::io::ReaderStream::new(file);
250
251 self.put(
252 &Self::content_key(oid),
253 reqwest::Body::wrap_stream(stream),
254 length,
255 )
256 .await?;
257 }
258
259 self.put(
260 &Self::marker_key(ns, oid),
261 reqwest::Body::from(Vec::new()),
262 0,
263 )
264 .await
265 }
266
267 pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
280 let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
281 action.headers_mut().insert("if-none-match", "*");
282 let url = action.sign(self.lifetime);
283
284 let length = body.len();
285 let response = self
286 .client
287 .put(url)
288 .header("if-none-match", "*")
289 .header(reqwest::header::CONTENT_LENGTH, length)
290 .body(body)
291 .send()
292 .await
293 .map_err(|_| unreachable_store())?;
294
295 if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
296 return Ok(false);
297 }
298
299 self.expect_success(response, "write").await?;
300
301 Ok(true)
302 }
303
304 pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
305 let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
306 let response = self
307 .client
308 .get(action.sign(self.lifetime))
309 .send()
310 .await
311 .map_err(|_| unreachable_store())?;
312
313 if response.status() == reqwest::StatusCode::NOT_FOUND {
314 return Ok(None);
315 }
316
317 let response = self.expect_success(response, "read").await?;
318
319 response
320 .bytes()
321 .await
322 .map(|bytes| Some(bytes.to_vec()))
323 .map_err(|_| unreachable_store())
324 }
325
326 pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
327 let existed = self.head(key).await.is_ok();
330
331 let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
332 let response = self
333 .client
334 .delete(action.sign(self.lifetime))
335 .send()
336 .await
337 .map_err(|_| unreachable_store())?;
338
339 self.expect_success(response, "delete").await?;
340
341 Ok(existed)
342 }
343
344 pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
349 let mut out = Vec::new();
350 let mut token: Option<String> = None;
351
352 loop {
353 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
354 action.with_prefix(prefix);
355 if let Some(token) = &token {
356 action.with_continuation_token(token);
357 }
358
359 let response = self
360 .client
361 .get(action.sign(self.lifetime))
362 .send()
363 .await
364 .map_err(|_| unreachable_store())?;
365 let body = self
366 .expect_success(response, "list")
367 .await?
368 .text()
369 .await
370 .map_err(|_| unreachable_store())?;
371
372 let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
373 Error::Storage(std::io::Error::other(format!(
374 "the object store sent a listing this server could not read: {error}"
375 )))
376 })?;
377
378 out.extend(listing.contents.into_iter().map(|object| object.key));
379
380 match listing.next_continuation_token {
381 Some(next) => token = Some(next),
382 None => break,
383 }
384 }
385
386 Ok(out)
387 }
388
389 async fn expect_success(
390 &self,
391 response: reqwest::Response,
392 what: &str,
393 ) -> Result<reqwest::Response, Error> {
394 let status = response.status();
395 if status.is_success() {
396 return Ok(response);
397 }
398
399 let detail = response.text().await.unwrap_or_default();
400
401 Err(Error::Storage(std::io::Error::other(format!(
402 "the object store refused a {what} with {status}: {}",
403 detail.trim()
404 ))))
405 }
406
407 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
412 let prefix = format!("{}/{}/", ns.org(), ns.repo());
413 let mut objects = 0;
414 let mut bytes = 0;
415
416 for oid in self.list(&prefix).await {
417 objects += 1;
418 bytes += self.size_of(&oid).await.unwrap_or_default();
419 }
420
421 (objects, bytes)
422 }
423
424 async fn list(&self, prefix: &str) -> Vec<String> {
425 let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
426 action.with_prefix(prefix);
427
428 let response = match self.client.get(action.sign(self.lifetime)).send().await {
432 Ok(response) => response,
433 Err(error) => {
434 tracing::warn!(%error, "the object store could not be listed");
435 return Vec::new();
436 }
437 };
438
439 let body = match response.text().await {
440 Ok(body) => body,
441 Err(error) => {
442 tracing::warn!(%error, "the listing could not be read");
443 return Vec::new();
444 }
445 };
446
447 let listing = match ListObjectsV2::parse_response(&body) {
448 Ok(listing) => listing,
449 Err(error) => {
450 tracing::warn!(%error, "the listing could not be parsed");
451 return Vec::new();
452 }
453 };
454
455 listing
456 .contents
457 .into_iter()
458 .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
459 .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
460 .collect()
461 }
462}
463
464#[cfg(test)]
465pub(crate) mod tests;