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 crate::error::Error;
9use crate::namespace::Namespace;
10
11// The same layout as the local store, for the same reasons. The bytes live once
12// under a key derived from their digest, and a repository that holds them owns
13// an empty marker beside it — the object store's answer to a hard link. It is
14// what keeps two projects sharing an asset pack from paying twice, and what
15// stops a repository reading an object it never pushed: the marker is the proof
16// of possession, and it is the only thing the permission check consults.
17fn unreachable_store() -> Error {
18    Error::Storage(std::io::Error::other("the object store is unreachable"))
19}
20
21#[derive(Clone)]
22pub struct S3Store {
23    bucket: Bucket,
24    credentials: Credentials,
25    client: reqwest::Client,
26    lifetime: Duration,
27    redirect: bool,
28}
29
30pub struct S3Config {
31    pub endpoint: String,
32    pub bucket: String,
33    pub region: String,
34    pub access_key: String,
35    pub secret_key: String,
36    pub path_style: bool,
37    pub redirect: bool,
38    // How long a signature is good for. It is the same number the batch
39    // response advertises as `expires_in`, because a client told it has half an
40    // hour and handed a URL that dies in five minutes will fail a resume it had
41    // every reason to expect to work.
42    pub lifetime: Duration,
43}
44
45impl S3Store {
46    pub fn new(config: &S3Config) -> Result<Self, Error> {
47        crate::tls::install_crypto_provider();
48
49        let style = if config.path_style {
50            UrlStyle::Path
51        } else {
52            UrlStyle::VirtualHost
53        };
54
55        let bucket = Bucket::new(
56            config
57                .endpoint
58                .parse()
59                .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
60            style,
61            config.bucket.clone(),
62            config.region.clone(),
63        )
64        .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
65
66        Ok(Self {
67            bucket,
68            credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
69            client: reqwest::Client::new(),
70            lifetime: config.lifetime,
71            redirect: config.redirect,
72        })
73    }
74
75    fn content_key(oid: &str) -> String {
76        format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
77    }
78
79    fn marker_key(ns: &Namespace, oid: &str) -> String {
80        format!(
81            "{}/{}/{}/{}/{oid}",
82            ns.org(),
83            ns.repo(),
84            &oid[0..2],
85            &oid[2..4]
86        )
87    }
88
89    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
90        if crate::storage::LocalStore::validate_oid(oid).is_err() {
91            return false;
92        }
93
94        self.head(&Self::marker_key(ns, oid)).await.is_ok()
95    }
96
97    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
98    // method, and an implementation that checks it — which is the point of
99    // testing against MinIO and Garage rather than only AWS — is entitled to
100    // refuse the mismatch.
101    async fn head(&self, key: &str) -> Result<u64, Error> {
102        let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
103        let url = action.sign(self.lifetime);
104
105        let response = self.client.head(url).send().await.map_err(|_| {
106            Error::Storage(std::io::Error::other("the object store is unreachable"))
107        })?;
108
109        if !response.status().is_success() {
110            return Err(Error::NotFound);
111        }
112
113        // Read the header rather than the body length: a HEAD has no body, and
114        // asking the response how long it is answers about what was received
115        // rather than what is there.
116        response
117            .headers()
118            .get(reqwest::header::CONTENT_LENGTH)
119            .and_then(|value| value.to_str().ok())
120            .and_then(|value| value.parse().ok())
121            .ok_or_else(|| {
122                Error::Storage(std::io::Error::other(
123                    "the object store gave no object size",
124                ))
125            })
126    }
127
128    pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
129        // Every entry point validates before slicing an oid into a key: the
130        // fanout takes the first four characters, so a short one is a panic
131        // rather than a refusal, and a panic is a 500 for something that should
132        // have been a 422.
133        crate::storage::LocalStore::validate_oid(oid)?;
134
135        self.head(&Self::content_key(oid)).await
136    }
137
138    // A download is streamed through this server rather than redirected, so the
139    // features that live in the byte path — the counters, the ranges, and the
140    // compression that will follow — keep working. The pre-signed redirect is a
141    // separate mode for operators who would rather spend the object store's
142    // bandwidth than their own.
143    pub async fn read(
144        &self,
145        oid: &str,
146        start: u64,
147        length: u64,
148    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
149        crate::storage::LocalStore::validate_oid(oid)?;
150
151        let key = Self::content_key(oid);
152        let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
153        let url = action.sign(self.lifetime);
154
155        let response = self
156            .client
157            .get(url)
158            .header(
159                reqwest::header::RANGE,
160                format!("bytes={start}-{}", start + length.saturating_sub(1)),
161            )
162            .send()
163            .await
164            .map_err(|_| {
165                Error::Storage(std::io::Error::other("the object store is unreachable"))
166            })?;
167
168        if !response.status().is_success() {
169            return Err(Error::NotFound);
170        }
171
172        Ok(response.bytes_stream())
173    }
174
175    // A URL the client fetches from the bucket directly, so the bytes never
176    // cross this server. Whether the caller is entitled to them has already been
177    // settled by the marker before this is called: the signature is scoped to
178    // one content key and expires, and it grants nothing the batch response was
179    // not about to grant anyway.
180    pub fn presigned_download(&self, oid: &str) -> Option<String> {
181        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
182            return None;
183        }
184
185        let key = Self::content_key(oid);
186
187        Some(
188            GetObject::new(&self.bucket, Some(&self.credentials), &key)
189                .sign(self.lifetime)
190                .to_string(),
191        )
192    }
193
194    async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
195        let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
196        let url = action.sign(self.lifetime);
197
198        let response = self
199            .client
200            .put(url)
201            // S3 has no use for a chunked body and answers 501 rather than
202            // starting the upload. reqwest cannot infer a length from a stream,
203            // so it comes from the staging file being sent.
204            .header(reqwest::header::CONTENT_LENGTH, length)
205            .body(body)
206            .send()
207            .await
208            .map_err(|_| {
209                Error::Storage(std::io::Error::other("the object store is unreachable"))
210            })?;
211
212        let status = response.status();
213        if !status.is_success() {
214            // The store says why in the body, and an operator staring at a
215            // failing push has nothing else to go on: a bucket that does not
216            // exist, a key that is denied and a clock that has drifted are three
217            // different afternoons.
218            let detail = response.text().await.unwrap_or_default();
219
220            return Err(Error::Storage(std::io::Error::other(format!(
221                "the object store refused a write with {status}: {}",
222                detail.trim()
223            ))));
224        }
225
226        Ok(())
227    }
228
229    // The upload has already been streamed to a staging file, hashed and checked
230    // against everything the server enforces, so that file is what goes up —
231    // streamed from disk rather than read into memory, because an object here is
232    // measured in gigabytes and the whole storage layer is built on holding at
233    // most a few megabytes of one at a time.
234    //
235    // The bytes go up once, keyed by their digest, and the marker records that
236    // this repository holds them. Content that is already there is skipped: the
237    // key would receive the same bytes it already has.
238    pub async fn store(
239        &self,
240        ns: &Namespace,
241        oid: &str,
242        staged: &std::path::Path,
243    ) -> Result<(), Error> {
244        crate::storage::LocalStore::validate_oid(oid)?;
245
246        if self.head(&Self::content_key(oid)).await.is_err() {
247            let file = tokio::fs::File::open(staged).await?;
248            let length = file.metadata().await?.len();
249            let stream = tokio_util::io::ReaderStream::new(file);
250
251            self.put(
252                &Self::content_key(oid),
253                reqwest::Body::wrap_stream(stream),
254                length,
255            )
256            .await?;
257        }
258
259        self.put(
260            &Self::marker_key(ns, oid),
261            reqwest::Body::from(Vec::new()),
262            0,
263        )
264        .await
265    }
266
267    // Everything below is the bucket as a keyspace rather than as an object
268    // store: whole small values, written, read, deleted and listed by key. The
269    // lock store is built on it, and it is kept here so the signing and the
270    // client stay in one place.
271
272    // The mutual exclusion `create_new` gives on a filesystem, asked of S3.
273    // `If-None-Match: *` is a conditional write: the store itself decides who
274    // arrived first, and answers 412 to everyone after. Without it two replicas
275    // sharing a bucket would each believe they took the lock.
276    //
277    // The header is bound into the signature and sent alongside, so a store that
278    // ignores conditional writes cannot silently accept both.
279    pub(crate) async fn put_if_absent(&self, key: &str, body: Vec<u8>) -> Result<bool, Error> {
280        let mut action = PutObject::new(&self.bucket, Some(&self.credentials), key);
281        action.headers_mut().insert("if-none-match", "*");
282        let url = action.sign(self.lifetime);
283
284        let length = body.len();
285        let response = self
286            .client
287            .put(url)
288            .header("if-none-match", "*")
289            .header(reqwest::header::CONTENT_LENGTH, length)
290            .body(body)
291            .send()
292            .await
293            .map_err(|_| unreachable_store())?;
294
295        if response.status() == reqwest::StatusCode::PRECONDITION_FAILED {
296            return Ok(false);
297        }
298
299        self.expect_success(response, "write").await?;
300
301        Ok(true)
302    }
303
304    pub(crate) async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
305        let action = GetObject::new(&self.bucket, Some(&self.credentials), key);
306        let response = self
307            .client
308            .get(action.sign(self.lifetime))
309            .send()
310            .await
311            .map_err(|_| unreachable_store())?;
312
313        if response.status() == reqwest::StatusCode::NOT_FOUND {
314            return Ok(None);
315        }
316
317        let response = self.expect_success(response, "read").await?;
318
319        response
320            .bytes()
321            .await
322            .map(|bytes| Some(bytes.to_vec()))
323            .map_err(|_| unreachable_store())
324    }
325
326    pub(crate) async fn delete(&self, key: &str) -> Result<bool, Error> {
327        // S3 answers 204 whether or not the key was there, so whether this
328        // removed anything is settled before asking.
329        let existed = self.head(key).await.is_ok();
330
331        let action = DeleteObject::new(&self.bucket, Some(&self.credentials), key);
332        let response = self
333            .client
334            .delete(action.sign(self.lifetime))
335            .send()
336            .await
337            .map_err(|_| unreachable_store())?;
338
339        self.expect_success(response, "delete").await?;
340
341        Ok(existed)
342    }
343
344    // Every key under a prefix, following the continuation token to the end.
345    // Stopping at the first page would report a repository holding a thousand
346    // locks as holding a thousand and none of the rest, and a lock nobody can
347    // see is a lock nobody respects.
348    pub(crate) async fn keys(&self, prefix: &str) -> Result<Vec<String>, Error> {
349        let mut out = Vec::new();
350        let mut token: Option<String> = None;
351
352        loop {
353            let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
354            action.with_prefix(prefix);
355            if let Some(token) = &token {
356                action.with_continuation_token(token);
357            }
358
359            let response = self
360                .client
361                .get(action.sign(self.lifetime))
362                .send()
363                .await
364                .map_err(|_| unreachable_store())?;
365            let body = self
366                .expect_success(response, "list")
367                .await?
368                .text()
369                .await
370                .map_err(|_| unreachable_store())?;
371
372            let listing = ListObjectsV2::parse_response(&body).map_err(|error| {
373                Error::Storage(std::io::Error::other(format!(
374                    "the object store sent a listing this server could not read: {error}"
375                )))
376            })?;
377
378            out.extend(listing.contents.into_iter().map(|object| object.key));
379
380            match listing.next_continuation_token {
381                Some(next) => token = Some(next),
382                None => break,
383            }
384        }
385
386        Ok(out)
387    }
388
389    async fn expect_success(
390        &self,
391        response: reqwest::Response,
392        what: &str,
393    ) -> Result<reqwest::Response, Error> {
394        let status = response.status();
395        if status.is_success() {
396            return Ok(response);
397        }
398
399        let detail = response.text().await.unwrap_or_default();
400
401        Err(Error::Storage(std::io::Error::other(format!(
402            "the object store refused a {what} with {status}: {}",
403            detail.trim()
404        ))))
405    }
406
407    // What the bucket holds for this repository, counted from its markers and
408    // the content they point at. The markers are empty, so their own size says
409    // nothing — this is a listing plus one head per object, which is why the
410    // figure is cached the same way the local one is.
411    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
412        let prefix = format!("{}/{}/", ns.org(), ns.repo());
413        let mut objects = 0;
414        let mut bytes = 0;
415
416        for oid in self.list(&prefix).await {
417            objects += 1;
418            bytes += self.size_of(&oid).await.unwrap_or_default();
419        }
420
421        (objects, bytes)
422    }
423
424    async fn list(&self, prefix: &str) -> Vec<String> {
425        let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
426        action.with_prefix(prefix);
427
428        // Every step logs what stopped it rather than returning an empty
429        // listing: a capacity figure that silently reads zero is worse than one
430        // that is missing, because it looks like an answer.
431        let response = match self.client.get(action.sign(self.lifetime)).send().await {
432            Ok(response) => response,
433            Err(error) => {
434                tracing::warn!(%error, "the object store could not be listed");
435                return Vec::new();
436            }
437        };
438
439        let body = match response.text().await {
440            Ok(body) => body,
441            Err(error) => {
442                tracing::warn!(%error, "the listing could not be read");
443                return Vec::new();
444            }
445        };
446
447        let listing = match ListObjectsV2::parse_response(&body) {
448            Ok(listing) => listing,
449            Err(error) => {
450                tracing::warn!(%error, "the listing could not be parsed");
451                return Vec::new();
452            }
453        };
454
455        listing
456            .contents
457            .into_iter()
458            .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
459            .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
460            .collect()
461    }
462}
463
464#[cfg(test)]
465pub(crate) mod tests;