Skip to main content

lfsx_server/storage/s3/
keyspace.rs

1use std::time::Duration;
2
3use axum::body::Bytes;
4use futures_util::Stream;
5use rusty_s3::actions::{
6    DeleteObject, GetObject, HeadBucket, HeadObject, ListObjectsV2, PutObject, S3Action,
7};
8use rusty_s3::{Bucket, Credentials, UrlStyle};
9
10use crate::error::Error;
11use crate::storage::s3::S3Config;
12
13const COPY_SOURCE: &str = "x-amz-copy-source";
14
15// One key as the store describes it.
16// Everything a listing found, and whether it ran out before the end.
17pub(crate) struct Listing {
18    pub(crate) entries: Vec<Entry>,
19    pub(crate) complete: bool,
20}
21
22pub(crate) struct Entry {
23    pub(crate) key: String,
24    last_modified: String,
25    pub(crate) size: u64,
26}
27
28impl Entry {
29    // None when the store's timestamp cannot be read, which is treated as "too
30    // young to touch": deleting somebody's upload on the strength of a date this
31    // server could not parse is the wrong way to be wrong.
32    pub(crate) fn age(&self) -> Option<Duration> {
33        let written = time::OffsetDateTime::parse(
34            &self.last_modified,
35            &time::format_description::well_known::Rfc3339,
36        )
37        .ok()?;
38
39        Duration::try_from(time::OffsetDateTime::now_utc() - written).ok()
40    }
41}
42
43// An href a client uses directly, and the headers it has to send with it. The
44// headers are part of the signature, so they are not advice.
45pub struct Presigned {
46    pub href: String,
47    pub headers: Vec<(String, String)>,
48}
49
50// The same layout as the local store, for the same reasons. The bytes live once
51// under a key derived from their digest, and a repository that holds them owns
52// an empty marker beside it — the object store's answer to a hard link. It is
53// what keeps two projects sharing an asset pack from paying twice, and what
54// stops a repository reading an object it never pushed: the marker is the proof
55// of possession, and it is the only thing the permission check consults.
56// A conditional write that is refused makes the store answer and hang up, and
57// the connection goes back into the pool looking usable. The next request on it
58// fails at the transport layer with nothing to do with the store's health, which
59// is how a losing `git lfs lock` came back as a 500 instead of a 409.
60//
61// Retried once, and only for requests that carry no body: a GET and a HEAD can be
62// repeated with no consequence, so a dead connection costs a round trip rather
63// than an error. A PUT is not retried here.
64async fn read_retrying(request: reqwest::RequestBuilder) -> Result<reqwest::Response, Error> {
65    let retry = request.try_clone();
66
67    match request.send().await {
68        Ok(response) => Ok(response),
69        Err(_) => match retry {
70            Some(retry) => retry.send().await.map_err(|_| unreachable_store()),
71            None => Err(unreachable_store()),
72        },
73    }
74}
75
76fn unreachable_store() -> Error {
77    Error::Storage(std::io::Error::other("the object store is unreachable"))
78}
79
80// The bucket as a keyspace: whole values written, read, deleted and listed by
81// key, with the signing and the HTTP client in one place. It knows nothing about
82// objects, oids or repositories — what a key means is decided a layer up, which
83// is what lets the lock store share the bucket with the object store without
84// either of them reaching into the other.
85#[derive(Clone)]
86pub struct Keyspace {
87    bucket: Bucket,
88    credentials: Credentials,
89    client: reqwest::Client,
90    lifetime: Duration,
91}
92
93impl Keyspace {
94    pub fn new(config: &S3Config) -> Result<Self, Error> {
95        crate::tls::install_crypto_provider();
96
97        let style = if config.path_style {
98            UrlStyle::Path
99        } else {
100            UrlStyle::VirtualHost
101        };
102
103        let bucket = Bucket::new(
104            config
105                .endpoint
106                .parse()
107                .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
108            style,
109            config.bucket.clone(),
110            config.region.clone(),
111        )
112        .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
113
114        Ok(Self {
115            bucket,
116            credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
117            client: reqwest::Client::new(),
118            lifetime: config.lifetime,
119        })
120    }
121
122    // Whether this server can reach the store at all, which is one HEAD on the
123    // bucket. Only the status is reported: the store says why in a body that
124    // names the bucket, and readiness is answered to whoever asks.
125    pub(crate) async fn reachable(&self) -> Result<(), Error> {
126        let action = HeadBucket::new(&self.bucket, Some(&self.credentials));
127        let response = read_retrying(self.client.head(action.sign(self.lifetime))).await?;
128
129        if !response.status().is_success() {
130            return Err(Error::Storage(std::io::Error::other(format!(
131                "the object store answered {} for the bucket",
132                response.status()
133            ))));
134        }
135
136        Ok(())
137    }
138
139    // A signature handed to a client so it reads the key straight from the store.
140    // For the one caller that has to send a request the way a client would,
141    // rather than the way this server does: the checksum probe puts a body
142    // against a URL it was handed, and it has to do it over the configured
143    // client so that the TLS, the proxy and the timeouts are the real ones.
144    // What it takes to sign an action this module does not itself perform. A
145    // multipart upload is four different actions sharing one upload id, which is
146    // a sequence rather than a key operation, so it lives beside this rather than
147    // inside it and borrows the signing material.
148    pub(crate) fn bucket(&self) -> &Bucket {
149        &self.bucket
150    }
151
152    pub(crate) fn credentials(&self) -> &Credentials {
153        &self.credentials
154    }
155
156    pub(crate) fn lifetime(&self) -> Duration {
157        self.lifetime
158    }
159
160    pub(crate) fn client(&self) -> &reqwest::Client {
161        &self.client
162    }
163
164    // Whether the caller is entitled to the bytes is settled before this is
165    // called: the signature is scoped to one key and it expires.
166    pub(crate) fn signed_download(&self, key: &str) -> String {
167        GetObject::new(&self.bucket, Some(&self.credentials), key)
168            .sign(self.lifetime)
169            .to_string()
170    }
171
172    // The same for a write, with headers bound into the signature rather than
173    // merely suggested: a conforming store refuses a body that does not match
174    // them, which is what makes handing out a write URL safe at all.
175    //
176    // Conforming is the load-bearing word, and it is not assumed. `probe` asks
177    // the store at startup whether it really does refuse, because a store that
178    // accepts the header and ignores it turns this from a guarantee into a hope.
179    pub(crate) fn signed_upload(&self, key: &str, headers: Vec<(String, String)>) -> Presigned {
180        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
181
182        for (name, value) in &headers {
183            action
184                .headers_mut()
185                .insert(name.clone(), std::borrow::Cow::Owned(value.clone()));
186        }
187
188        Presigned {
189            href: action.sign(self.lifetime).to_string(),
190            headers,
191        }
192    }
193
194    // A ranged read streamed rather than buffered: a value here can be measured
195    // in gigabytes, and the whole storage layer is built on holding at most a few
196    // megabytes of one at a time.
197    pub(crate) async fn get_range(
198        &self,
199        key: &str,
200        start: u64,
201        length: u64,
202    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
203        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
204
205        let response = self
206            .client
207            .get(action.sign(self.lifetime))
208            .header(
209                reqwest::header::RANGE,
210                format!("bytes={start}-{}", start + length.saturating_sub(1)),
211            )
212            .send()
213            .await
214            .map_err(|_| unreachable_store())?;
215
216        if !response.status().is_success() {
217            return Err(Error::NotFound);
218        }
219
220        Ok(response.bytes_stream())
221    }
222
223    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
224    // method, and an implementation that checks it — which is the point of
225    // testing against MinIO and Garage rather than only AWS — is entitled to
226    // refuse the mismatch.
227    pub(crate) async fn head(&self, key: &str) -> Result<u64, Error> {
228        let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
229        let url = action.sign(self.lifetime);
230
231        let response = read_retrying(self.client.head(url)).await?;
232
233        if !response.status().is_success() {
234            return Err(Error::NotFound);
235        }
236
237        // Read the header rather than the body length: a HEAD has no body, and
238        // asking the response how long it is answers about what was received
239        // rather than what is there.
240        response
241            .headers()
242            .get(reqwest::header::CONTENT_LENGTH)
243            .and_then(|value| value.to_str().ok())
244            .and_then(|value| value.parse().ok())
245            .ok_or_else(|| {
246                Error::Storage(std::io::Error::other(
247                    "the object store gave no object size",
248                ))
249            })
250    }
251
252    pub(crate) async fn put(
253        &self,
254        key: &str,
255        body: reqwest::Body,
256        length: u64,
257    ) -> Result<(), Error> {
258        let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
259        let url = action.sign(self.lifetime);
260
261        let response = self
262            .client
263            .put(url)
264            // S3 has no use for a chunked body and answers 501 rather than
265            // starting the upload. reqwest cannot infer a length from a stream,
266            // so it comes from the staging file being sent.
267            .header(reqwest::header::CONTENT_LENGTH, length)
268            .body(body)
269            .send()
270            .await
271            .map_err(|_| {
272                Error::Storage(std::io::Error::other("the object store is unreachable"))
273            })?;
274
275        let status = response.status();
276        if !status.is_success() {
277            // The store says why in the body, and an operator staring at a
278            // failing push has nothing else to go on: a bucket that does not
279            // exist, a key that is denied and a clock that has drifted are three
280            // different afternoons.
281            let detail = response.text().await.unwrap_or_default();
282
283            return Err(Error::Storage(std::io::Error::other(format!(
284                "the object store refused a write with {status}: {}",
285                detail.trim()
286            ))));
287        }
288
289        Ok(())
290    }
291
292    // A copy is a PUT to the destination carrying `x-amz-copy-source`, so this is
293    // a signed PutObject with that header bound rather than a separate action.
294    // The bytes move inside the store: nothing crosses this server.
295    pub(crate) async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
296        let source = format!("/{}/{from}", self.bucket.name());
297        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), to);
298        action
299            .headers_mut()
300            .insert(COPY_SOURCE, std::borrow::Cow::Owned(source.clone()));
301
302        let response = self
303            .client
304            .put(action.sign(self.lifetime))
305            .header(COPY_SOURCE, source)
306            .header(reqwest::header::CONTENT_LENGTH, 0)
307            .send()
308            .await
309            .map_err(|_| unreachable_store())?;
310
311        self.expect_success(response, "copy").await?;
312
313        Ok(())
314    }
315
316    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
317    // `If-None-Match: *` is a conditional write: the store itself decides who
318    // arrived first, and answers 412 to everyone after. Without it two replicas
319    // sharing a bucket would each believe they took the lock.
320    //
321    // The header is bound into the signature and sent alongside, so a store that
322    // ignores conditional writes cannot silently accept both.
323    pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
324        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
325        action.headers_mut().insert("if-none-match", "*");
326        let url = action.sign(self.lifetime);
327
328        let length = body.len();
329        let response = self
330            .client
331            .put(url)
332            .header("if-none-match", "*")
333            .header(reqwest::header::CONTENT_LENGTH, length)
334            .body(body)
335            .send()
336            .await
337            .map_err(|_| unreachable_store())?;
338
339        if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
340            return Ok(false);
341        }
342
343        self.expect_success(response, "write").await?;
344
345        Ok(true)
346    }
347
348    pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
349        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
350        let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
351
352        if response.status() == reqwest::StatusCode::NOT_FOUND {
353            return Ok(None);
354        }
355
356        let response = self.expect_success(response, "read").await?;
357
358        response
359            .bytes()
360            .await
361            .map(|bytes| Some(bytes.to_vec()))
362            .map_err(|_| unreachable_store())
363    }
364
365    pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
366        // S3 answers 204 whether or not the key was there, so whether this
367        // removed anything is settled before asking.
368        let existed = self.head(key).await.is_ok();
369
370        let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
371        let response = self
372            .client
373            .delete(action.sign(self.lifetime))
374            .send()
375            .await
376            .map_err(|_| unreachable_store())?;
377
378        self.expect_success(response, "delete").await?;
379
380        Ok(existed)
381    }
382
383    // Every key under a prefix, following the continuation token to the end.
384    // Stopping at the first page would report a repository holding a thousand
385    // locks as holding a thousand and none of the rest, and a lock nobody can
386    // see is a lock nobody respects.
387    pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
388        Ok(self
389            .entries(prefix)
390            .await?
391            .into_iter()
392            .map(|entry| entry.key)
393            .collect())
394    }
395
396    // A listing that says whether it finished. Collection needs the difference:
397    // concluding "no marker anywhere references this object" from a listing that
398    // stopped halfway is how a sweep deletes bytes another repository still
399    // holds. Everything else wants the strict form and gets `entries`.
400    pub(crate) async fn listing(&self, prefix: &str) -> Listing {
401        match self.entries(prefix).await {
402            Ok(entries) => Listing {
403                entries,
404                complete: true,
405            },
406            Err(error) => {
407                tracing::warn!(%error, prefix, "the listing could not be finished");
408                Listing {
409                    entries: Vec::new(),
410                    complete: false,
411                }
412            }
413        }
414    }
415
416    pub(crate) async fn entries(&self, prefix: &str) -> Result<Vec<Entry>, Error> {
417        let mut out = Vec::new();
418        let mut token: Option<String> = None;
419
420        loop {
421            let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
422            action.with_prefix(prefix);
423            if let Some(token) = &token {
424                action.with_continuation_token(token);
425            }
426
427            let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
428            let body = self
429                .expect_success(response, "list")
430                .await?
431                .text()
432                .await
433                .map_err(|_| unreachable_store())?;
434
435            let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
436                Error::Storage(std::io::Error::other(format!(
437                    "the object store sent a listing this server could not read: {error}"
438                )))
439            })?;
440
441            out.extend(listing.contents.into_iter().map(|object| Entry {
442                key: object.key,
443                last_modified: object.last_modified,
444                size: object.size,
445            }));
446
447            match listing.next_continuation_token {
448                Some(next) => token = Some(next),
449                None => break,
450            }
451        }
452
453        Ok(out)
454    }
455
456    pub(crate) async fn expect_success(
457        &self,
458        response: reqwest::Response,
459        what: &str,
460    ) -> Result<reqwest::Response, Error> {
461        let status = response.status();
462        if status.is_success() {
463            return Ok(response);
464        }
465
466        let detail = response.text().await.unwrap_or_default();
467
468        Err(Error::Storage(std::io::Error::other(format!(
469            "the object store refused a {what} with {status}: {}",
470            detail.trim()
471        ))))
472    }
473}