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;
12use crate::storage::Reclaimed;
13
14const CHECKSUM: &str = "x-amz-checksum-sha256";
15const COPY_SOURCE: &str = "x-amz-copy-source";
16
17// One key as the store describes it.
18struct Entry {
19    key: String,
20    last_modified: String,
21    size: u64,
22}
23
24impl Entry {
25    // None when the store's timestamp cannot be read, which is treated as "too
26    // young to touch": deleting somebody's upload on the strength of a date this
27    // server could not parse is the wrong way to be wrong.
28    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
39// An href a client uses directly, and the headers it has to send with it. The
40// headers are part of the signature, so they are not advice.
41pub struct Presigned {
42    pub href: String,
43    pub headers: Vec<(String, String)>,
44}
45
46// The same layout as the local store, for the same reasons. The bytes live once
47// under a key derived from their digest, and a repository that holds them owns
48// an empty marker beside it — the object store's answer to a hard link. It is
49// what keeps two projects sharing an asset pack from paying twice, and what
50// stops a repository reading an object it never pushed: the marker is the proof
51// of possession, and it is the only thing the permission check consults.
52// A conditional write that is refused makes the store answer and hang up, and
53// the connection goes back into the pool looking usable. The next request on it
54// fails at the transport layer with nothing to do with the store's health, which
55// is how a losing `git lfs lock` came back as a 500 instead of a 409.
56//
57// Retried once, and only for requests that carry no body: a GET and a HEAD can be
58// repeated with no consequence, so a dead connection costs a round trip rather
59// than an error. A PUT is not retried here.
60async 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    // How long a signature is good for. It is the same number the batch
94    // response advertises as `expires_in`, because a client told it has half an
95    // hour and handed a URL that dies in five minutes will fail a resume it had
96    // every reason to expect to work.
97    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    // Where a client uploads to when the bytes never pass through this server.
135    // Per repository on purpose: the shared content key would take bytes from
136    // anyone allowed to write, and then nothing distinguishes a repository that
137    // uploaded an object from one that merely knew its digest. A key only this
138    // repository was handed a signature for is the proof of possession that the
139    // marker stands for everywhere else.
140    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    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
169    // method, and an implementation that checks it — which is the point of
170    // testing against MinIO and Garage rather than only AWS — is entitled to
171    // refuse the mismatch.
172    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        // Read the header rather than the body length: a HEAD has no body, and
183        // asking the response how long it is answers about what was received
184        // rather than what is there.
185        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        // Every entry point validates before slicing an oid into a key: the
199        // fanout takes the first four characters, so a short one is a panic
200        // rather than a refusal, and a panic is a 500 for something that should
201        // have been a 422.
202        crate::storage::LocalStore::validate_oid(oid)?;
203
204        self.head(&Self::content_key(oid)).await
205    }
206
207    // A download is streamed through this server rather than redirected, so the
208    // features that live in the byte path — the counters, the ranges, and the
209    // compression that will follow — keep working. The pre-signed redirect is a
210    // separate mode for operators who would rather spend the object store's
211    // bandwidth than their own.
212    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    // A URL the client fetches from the bucket directly, so the bytes never
245    // cross this server. Whether the caller is entitled to them has already been
246    // settled by the marker before this is called: the signature is scoped to
247    // one content key and expires, and it grants nothing the batch response was
248    // not about to grant anyway.
249    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    // A URL the client PUTs the object to, and the headers it has to send with
264    // it. The digest is bound into the signature, so the store refuses anything
265    // that does not hash to the object it was signed for: a client with this URL
266    // cannot put arbitrary bytes anywhere, which is what makes handing one out
267    // safe at all.
268    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    // How big the object a client uploaded actually is, which is the first thing
287    // this server learns about it: nothing measured the bytes on the way past.
288    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    // Take an upload that landed under this repository's own key into the shared
295    // keyspace. The bytes are already known to hash to the oid, because the store
296    // refused everything else.
297    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        // Already there means another repository pushed the same object, and the
304        // bytes are identical by construction.
305        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        // Leaving it would pay for the object twice. A failure here is not worth
317        // failing the push over: the object is adopted, and what is left is a key
318        // the operator can see.
319        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    // A copy is a PUT to the destination carrying `x-amz-copy-source`, so this is
327    // a signed PutObject with that header bound rather than a separate action.
328    // The bytes move inside the store: nothing crosses this server.
329    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            // S3 has no use for a chunked body and answers 501 rather than
358            // starting the upload. reqwest cannot infer a length from a stream,
359            // so it comes from the staging file being sent.
360            .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            // The store says why in the body, and an operator staring at a
371            // failing push has nothing else to go on: a bucket that does not
372            // exist, a key that is denied and a clock that has drifted are three
373            // different afternoons.
374            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    // The upload has already been streamed to a staging file, hashed and checked
386    // against everything the server enforces, so that file is what goes up —
387    // streamed from disk rather than read into memory, because an object here is
388    // measured in gigabytes and the whole storage layer is built on holding at
389    // most a few megabytes of one at a time.
390    //
391    // The bytes go up once, keyed by their digest, and the marker records that
392    // this repository holds them. Content that is already there is skipped: the
393    // key would receive the same bytes it already has.
394    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    // Everything below is the bucket as a keyspace rather than as an object
424    // store: whole small values, written, read, deleted and listed by key. The
425    // lock store is built on it, and it is kept here so the signing and the
426    // client stay in one place.
427
428    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
429    // `If-None-Match: *` is a conditional write: the store itself decides who
430    // arrived first, and answers 412 to everyone after. Without it two replicas
431    // sharing a bucket would each believe they took the lock.
432    //
433    // The header is bound into the signature and sent alongside, so a store that
434    // ignores conditional writes cannot silently accept both.
435    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        // S3 answers 204 whether or not the key was there, so whether this
479        // removed anything is settled before asking.
480        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    // What an interrupted upload leaves behind. A client can negotiate, PUT the
496    // object, and never report it: the bytes sit under its own upload key and
497    // nothing else will ever look at them. The local path has had a reclaimer for
498    // this since the beginning, and a bucket had none, so the cost was unbounded
499    // over time and invisible.
500    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            // A slow client on a bad connection is not an abandoned one.
505            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    // Every key under a prefix, following the continuation token to the end.
519    // Stopping at the first page would report a repository holding a thousand
520    // locks as holding a thousand and none of the rest, and a lock nobody can
521    // see is a lock nobody respects.
522    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    // What the bucket holds for this repository, counted from its markers and
590    // the content they point at. The markers are empty, so their own size says
591    // nothing — this is a listing plus one head per object, which is why the
592    // figure is cached the same way the local one is.
593    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        // Every step logs what stopped it rather than returning an empty
611        // listing: a capacity figure that silently reads zero is worse than one
612        // that is missing, because it looks like an answer.
613        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;