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