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    pub(crate) fn client(&self) -> &reqwest::Client {
145        &self.client
146    }
147
148    // Whether the caller is entitled to the bytes is settled before this is
149    // called: the signature is scoped to one key and it expires.
150    pub(crate) fn signed_download(&self, key: &str) -> String {
151        GetObject::new(&self.bucket, Some(&self.credentials), key)
152            .sign(self.lifetime)
153            .to_string()
154    }
155
156    // The same for a write, with headers bound into the signature rather than
157    // merely suggested: a conforming store refuses a body that does not match
158    // them, which is what makes handing out a write URL safe at all.
159    //
160    // Conforming is the load-bearing word, and it is not assumed. `probe` asks
161    // the store at startup whether it really does refuse, because a store that
162    // accepts the header and ignores it turns this from a guarantee into a hope.
163    pub(crate) fn signed_upload(&self, key: &str, headers: Vec<(String, String)>) -> Presigned {
164        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
165
166        for (name, value) in &headers {
167            action
168                .headers_mut()
169                .insert(name.clone(), std::borrow::Cow::Owned(value.clone()));
170        }
171
172        Presigned {
173            href: action.sign(self.lifetime).to_string(),
174            headers,
175        }
176    }
177
178    // A ranged read streamed rather than buffered: a value here can be measured
179    // in gigabytes, and the whole storage layer is built on holding at most a few
180    // megabytes of one at a time.
181    pub(crate) async fn get_range(
182        &self,
183        key: &str,
184        start: u64,
185        length: u64,
186    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
187        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
188
189        let response = self
190            .client
191            .get(action.sign(self.lifetime))
192            .header(
193                reqwest::header::RANGE,
194                format!("bytes={start}-{}", start + length.saturating_sub(1)),
195            )
196            .send()
197            .await
198            .map_err(|_| unreachable_store())?;
199
200        if !response.status().is_success() {
201            return Err(Error::NotFound);
202        }
203
204        Ok(response.bytes_stream())
205    }
206
207    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
208    // method, and an implementation that checks it — which is the point of
209    // testing against MinIO and Garage rather than only AWS — is entitled to
210    // refuse the mismatch.
211    pub(crate) async fn head(&self, key: &str) -> Result<u64, Error> {
212        let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
213        let url = action.sign(self.lifetime);
214
215        let response = read_retrying(self.client.head(url)).await?;
216
217        if !response.status().is_success() {
218            return Err(Error::NotFound);
219        }
220
221        // Read the header rather than the body length: a HEAD has no body, and
222        // asking the response how long it is answers about what was received
223        // rather than what is there.
224        response
225            .headers()
226            .get(reqwest::header::CONTENT_LENGTH)
227            .and_then(|value| value.to_str().ok())
228            .and_then(|value| value.parse().ok())
229            .ok_or_else(|| {
230                Error::Storage(std::io::Error::other(
231                    "the object store gave no object size",
232                ))
233            })
234    }
235
236    pub(crate) async fn put(
237        &self,
238        key: &str,
239        body: reqwest::Body,
240        length: u64,
241    ) -> Result<(), Error> {
242        let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
243        let url = action.sign(self.lifetime);
244
245        let response = self
246            .client
247            .put(url)
248            // S3 has no use for a chunked body and answers 501 rather than
249            // starting the upload. reqwest cannot infer a length from a stream,
250            // so it comes from the staging file being sent.
251            .header(reqwest::header::CONTENT_LENGTH, length)
252            .body(body)
253            .send()
254            .await
255            .map_err(|_| {
256                Error::Storage(std::io::Error::other("the object store is unreachable"))
257            })?;
258
259        let status = response.status();
260        if !status.is_success() {
261            // The store says why in the body, and an operator staring at a
262            // failing push has nothing else to go on: a bucket that does not
263            // exist, a key that is denied and a clock that has drifted are three
264            // different afternoons.
265            let detail = response.text().await.unwrap_or_default();
266
267            return Err(Error::Storage(std::io::Error::other(format!(
268                "the object store refused a write with {status}: {}",
269                detail.trim()
270            ))));
271        }
272
273        Ok(())
274    }
275
276    // A copy is a PUT to the destination carrying `x-amz-copy-source`, so this is
277    // a signed PutObject with that header bound rather than a separate action.
278    // The bytes move inside the store: nothing crosses this server.
279    pub(crate) async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
280        let source = format!("/{}/{from}", self.bucket.name());
281        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), to);
282        action
283            .headers_mut()
284            .insert(COPY_SOURCE, std::borrow::Cow::Owned(source.clone()));
285
286        let response = self
287            .client
288            .put(action.sign(self.lifetime))
289            .header(COPY_SOURCE, source)
290            .header(reqwest::header::CONTENT_LENGTH, 0)
291            .send()
292            .await
293            .map_err(|_| unreachable_store())?;
294
295        self.expect_success(response, "copy").await?;
296
297        Ok(())
298    }
299
300    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
301    // `If-None-Match: *` is a conditional write: the store itself decides who
302    // arrived first, and answers 412 to everyone after. Without it two replicas
303    // sharing a bucket would each believe they took the lock.
304    //
305    // The header is bound into the signature and sent alongside, so a store that
306    // ignores conditional writes cannot silently accept both.
307    pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
308        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
309        action.headers_mut().insert("if-none-match", "*");
310        let url = action.sign(self.lifetime);
311
312        let length = body.len();
313        let response = self
314            .client
315            .put(url)
316            .header("if-none-match", "*")
317            .header(reqwest::header::CONTENT_LENGTH, length)
318            .body(body)
319            .send()
320            .await
321            .map_err(|_| unreachable_store())?;
322
323        if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
324            return Ok(false);
325        }
326
327        self.expect_success(response, "write").await?;
328
329        Ok(true)
330    }
331
332    pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
333        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
334        let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
335
336        if response.status() == reqwest::StatusCode::NOT_FOUND {
337            return Ok(None);
338        }
339
340        let response = self.expect_success(response, "read").await?;
341
342        response
343            .bytes()
344            .await
345            .map(|bytes| Some(bytes.to_vec()))
346            .map_err(|_| unreachable_store())
347    }
348
349    pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
350        // S3 answers 204 whether or not the key was there, so whether this
351        // removed anything is settled before asking.
352        let existed = self.head(key).await.is_ok();
353
354        let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
355        let response = self
356            .client
357            .delete(action.sign(self.lifetime))
358            .send()
359            .await
360            .map_err(|_| unreachable_store())?;
361
362        self.expect_success(response, "delete").await?;
363
364        Ok(existed)
365    }
366
367    // Every key under a prefix, following the continuation token to the end.
368    // Stopping at the first page would report a repository holding a thousand
369    // locks as holding a thousand and none of the rest, and a lock nobody can
370    // see is a lock nobody respects.
371    pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
372        Ok(self
373            .entries(prefix)
374            .await?
375            .into_iter()
376            .map(|entry| entry.key)
377            .collect())
378    }
379
380    // A listing that says whether it finished. Collection needs the difference:
381    // concluding "no marker anywhere references this object" from a listing that
382    // stopped halfway is how a sweep deletes bytes another repository still
383    // holds. Everything else wants the strict form and gets `entries`.
384    pub(crate) async fn listing(&self, prefix: &str) -> Listing {
385        match self.entries(prefix).await {
386            Ok(entries) => Listing {
387                entries,
388                complete: true,
389            },
390            Err(error) => {
391                tracing::warn!(%error, prefix, "the listing could not be finished");
392                Listing {
393                    entries: Vec::new(),
394                    complete: false,
395                }
396            }
397        }
398    }
399
400    pub(crate) async fn entries(&self, prefix: &str) -> Result<Vec<Entry>, Error> {
401        let mut out = Vec::new();
402        let mut token: Option<String> = None;
403
404        loop {
405            let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
406            action.with_prefix(prefix);
407            if let Some(token) = &token {
408                action.with_continuation_token(token);
409            }
410
411            let response = read_retrying(self.client.get(action.sign(self.lifetime))).await?;
412            let body = self
413                .expect_success(response, "list")
414                .await?
415                .text()
416                .await
417                .map_err(|_| unreachable_store())?;
418
419            let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
420                Error::Storage(std::io::Error::other(format!(
421                    "the object store sent a listing this server could not read: {error}"
422                )))
423            })?;
424
425            out.extend(listing.contents.into_iter().map(|object| Entry {
426                key: object.key,
427                last_modified: object.last_modified,
428                size: object.size,
429            }));
430
431            match listing.next_continuation_token {
432                Some(next) => token = Some(next),
433                None => break,
434            }
435        }
436
437        Ok(out)
438    }
439
440    async fn expect_success(
441        &self,
442        response: reqwest::Response,
443        what: &str,
444    ) -> Result<reqwest::Response, Error> {
445        let status = response.status();
446        if status.is_success() {
447            return Ok(response);
448        }
449
450        let detail = response.text().await.unwrap_or_default();
451
452        Err(Error::Storage(std::io::Error::other(format!(
453            "the object store refused a {what} with {status}: {}",
454            detail.trim()
455        ))))
456    }
457}