Skip to main content

lfsx_server/storage/
s3.rs

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
16// An href a client uses directly, and the headers it has to send with it. The
17// headers are part of the signature, so they are not advice.
18pub struct Presigned {
19    pub href: String,
20    pub headers: Vec<(String, String)>,
21}
22
23// The same layout as the local store, for the same reasons. The bytes live once
24// under a key derived from their digest, and a repository that holds them owns
25// an empty marker beside it — the object store's answer to a hard link. It is
26// what keeps two projects sharing an asset pack from paying twice, and what
27// stops a repository reading an object it never pushed: the marker is the proof
28// of possession, and it is the only thing the permission check consults.
29// A conditional write that is refused makes the store answer and hang up, and
30// the connection goes back into the pool looking usable. The next request on it
31// fails at the transport layer with nothing to do with the store's health, which
32// is how a losing `git lfs lock` came back as a 500 instead of a 409.
33//
34// Retried once, and only for requests that carry no body: a GET and a HEAD can be
35// repeated with no consequence, so a dead connection costs a round trip rather
36// than an error. A PUT is not retried here.
37async 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    // How long a signature is good for. It is the same number the batch
71    // response advertises as `expires_in`, because a client told it has half an
72    // hour and handed a URL that dies in five minutes will fail a resume it had
73    // every reason to expect to work.
74    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    // Where a client uploads to when the bytes never pass through this server.
112    // Per repository on purpose: the shared content key would take bytes from
113    // anyone allowed to write, and then nothing distinguishes a repository that
114    // uploaded an object from one that merely knew its digest. A key only this
115    // repository was handed a signature for is the proof of possession that the
116    // marker stands for everywhere else.
117    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    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
146    // method, and an implementation that checks it — which is the point of
147    // testing against MinIO and Garage rather than only AWS — is entitled to
148    // refuse the mismatch.
149    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        // Read the header rather than the body length: a HEAD has no body, and
160        // asking the response how long it is answers about what was received
161        // rather than what is there.
162        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        // Every entry point validates before slicing an oid into a key: the
176        // fanout takes the first four characters, so a short one is a panic
177        // rather than a refusal, and a panic is a 500 for something that should
178        // have been a 422.
179        crate::storage::LocalStore::validate_oid(oid)?;
180
181        self.head(&Self::content_key(oid)).await
182    }
183
184    // A download is streamed through this server rather than redirected, so the
185    // features that live in the byte path — the counters, the ranges, and the
186    // compression that will follow — keep working. The pre-signed redirect is a
187    // separate mode for operators who would rather spend the object store's
188    // bandwidth than their own.
189    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    // A URL the client fetches from the bucket directly, so the bytes never
222    // cross this server. Whether the caller is entitled to them has already been
223    // settled by the marker before this is called: the signature is scoped to
224    // one content key and expires, and it grants nothing the batch response was
225    // not about to grant anyway.
226    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    // A URL the client PUTs the object to, and the headers it has to send with
241    // it. The digest is bound into the signature, so the store refuses anything
242    // that does not hash to the object it was signed for: a client with this URL
243    // cannot put arbitrary bytes anywhere, which is what makes handing one out
244    // safe at all.
245    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    // How big the object a client uploaded actually is, which is the first thing
264    // this server learns about it: nothing measured the bytes on the way past.
265    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    // Take an upload that landed under this repository's own key into the shared
272    // keyspace. The bytes are already known to hash to the oid, because the store
273    // refused everything else.
274    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        // Already there means another repository pushed the same object, and the
281        // bytes are identical by construction.
282        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        // Leaving it would pay for the object twice. A failure here is not worth
294        // failing the push over: the object is adopted, and what is left is a key
295        // the operator can see.
296        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    // A copy is a PUT to the destination carrying `x-amz-copy-source`, so this is
304    // a signed PutObject with that header bound rather than a separate action.
305    // The bytes move inside the store: nothing crosses this server.
306    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            // S3 has no use for a chunked body and answers 501 rather than
335            // starting the upload. reqwest cannot infer a length from a stream,
336            // so it comes from the staging file being sent.
337            .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            // The store says why in the body, and an operator staring at a
348            // failing push has nothing else to go on: a bucket that does not
349            // exist, a key that is denied and a clock that has drifted are three
350            // different afternoons.
351            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    // The upload has already been streamed to a staging file, hashed and checked
363    // against everything the server enforces, so that file is what goes up —
364    // streamed from disk rather than read into memory, because an object here is
365    // measured in gigabytes and the whole storage layer is built on holding at
366    // most a few megabytes of one at a time.
367    //
368    // The bytes go up once, keyed by their digest, and the marker records that
369    // this repository holds them. Content that is already there is skipped: the
370    // key would receive the same bytes it already has.
371    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    // Everything below is the bucket as a keyspace rather than as an object
401    // store: whole small values, written, read, deleted and listed by key. The
402    // lock store is built on it, and it is kept here so the signing and the
403    // client stay in one place.
404
405    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
406    // `If-None-Match: *` is a conditional write: the store itself decides who
407    // arrived first, and answers 412 to everyone after. Without it two replicas
408    // sharing a bucket would each believe they took the lock.
409    //
410    // The header is bound into the signature and sent alongside, so a store that
411    // ignores conditional writes cannot silently accept both.
412    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        // S3 answers 204 whether or not the key was there, so whether this
456        // removed anything is settled before asking.
457        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    // Every key under a prefix, following the continuation token to the end.
473    // Stopping at the first page would report a repository holding a thousand
474    // locks as holding a thousand and none of the rest, and a lock nobody can
475    // see is a lock nobody respects.
476    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    // What the bucket holds for this repository, counted from its markers and
531    // the content they point at. The markers are empty, so their own size says
532    // nothing — this is a listing plus one head per object, which is why the
533    // figure is cached the same way the local one is.
534    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        // Every step logs what stopped it rather than returning an empty
552        // listing: a capacity figure that silently reads zero is worse than one
553        // that is missing, because it looks like an answer.
554        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;