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::{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.
17#[derive(Clone)]
18pub struct S3Store {
19    bucket: Bucket,
20    credentials: Credentials,
21    client: reqwest::Client,
22    lifetime: Duration,
23    redirect: bool,
24}
25
26pub struct S3Config {
27    pub endpoint: String,
28    pub bucket: String,
29    pub region: String,
30    pub access_key: String,
31    pub secret_key: String,
32    pub path_style: bool,
33    pub redirect: bool,
34    // How long a signature is good for. It is the same number the batch
35    // response advertises as `expires_in`, because a client told it has half an
36    // hour and handed a URL that dies in five minutes will fail a resume it had
37    // every reason to expect to work.
38    pub lifetime: Duration,
39}
40
41impl S3Store {
42    pub fn new(config: &S3Config) -> Result<Self, Error> {
43        let style = if config.path_style {
44            UrlStyle::Path
45        } else {
46            UrlStyle::VirtualHost
47        };
48
49        let bucket = Bucket::new(
50            config
51                .endpoint
52                .parse()
53                .map_err(|_| Error::Misconfigured("LFSX_S3_ENDPOINT is not a URL"))?,
54            style,
55            config.bucket.clone(),
56            config.region.clone(),
57        )
58        .map_err(|_| Error::Misconfigured("LFSX_S3_BUCKET is not a usable bucket name"))?;
59
60        Ok(Self {
61            bucket,
62            credentials: Credentials::new(config.access_key.clone(), config.secret_key.clone()),
63            client: reqwest::Client::new(),
64            lifetime: config.lifetime,
65            redirect: config.redirect,
66        })
67    }
68
69    fn content_key(oid: &str) -> String {
70        format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
71    }
72
73    fn marker_key(ns: &Namespace, oid: &str) -> String {
74        format!(
75            "{}/{}/{}/{}/{oid}",
76            ns.org(),
77            ns.repo(),
78            &oid[0..2],
79            &oid[2..4]
80        )
81    }
82
83    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
84        if crate::storage::LocalStore::validate_oid(oid).is_err() {
85            return false;
86        }
87
88        self.head(&Self::marker_key(ns, oid)).await.is_ok()
89    }
90
91    // Signed as a HEAD rather than reusing a GET signature: SigV4 covers the
92    // method, and an implementation that checks it — which is the point of
93    // testing against MinIO and Garage rather than only AWS — is entitled to
94    // refuse the mismatch.
95    async fn head(&self, key: &str) -> Result<u64, Error> {
96        let action = HeadObject::new(&self.bucket, Some(&self.credentials), key);
97        let url = action.sign(self.lifetime);
98
99        let response = self.client.head(url).send().await.map_err(|_| {
100            Error::Storage(std::io::Error::other("the object store is unreachable"))
101        })?;
102
103        if !response.status().is_success() {
104            return Err(Error::NotFound);
105        }
106
107        // Read the header rather than the body length: a HEAD has no body, and
108        // asking the response how long it is answers about what was received
109        // rather than what is there.
110        response
111            .headers()
112            .get(reqwest::header::CONTENT_LENGTH)
113            .and_then(|value| value.to_str().ok())
114            .and_then(|value| value.parse().ok())
115            .ok_or_else(|| {
116                Error::Storage(std::io::Error::other(
117                    "the object store gave no object size",
118                ))
119            })
120    }
121
122    pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
123        // Every entry point validates before slicing an oid into a key: the
124        // fanout takes the first four characters, so a short one is a panic
125        // rather than a refusal, and a panic is a 500 for something that should
126        // have been a 422.
127        crate::storage::LocalStore::validate_oid(oid)?;
128
129        self.head(&Self::content_key(oid)).await
130    }
131
132    // A download is streamed through this server rather than redirected, so the
133    // features that live in the byte path — the counters, the ranges, and the
134    // compression that will follow — keep working. The pre-signed redirect is a
135    // separate mode for operators who would rather spend the object store's
136    // bandwidth than their own.
137    pub async fn read(
138        &self,
139        oid: &str,
140        start: u64,
141        length: u64,
142    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
143        crate::storage::LocalStore::validate_oid(oid)?;
144
145        let key = Self::content_key(oid);
146        let action = GetObject::new(&self.bucket, Some(&self.credentials), &key);
147        let url = action.sign(self.lifetime);
148
149        let response = self
150            .client
151            .get(url)
152            .header(
153                reqwest::header::RANGE,
154                format!("bytes={start}-{}", start + length.saturating_sub(1)),
155            )
156            .send()
157            .await
158            .map_err(|_| {
159                Error::Storage(std::io::Error::other("the object store is unreachable"))
160            })?;
161
162        if !response.status().is_success() {
163            return Err(Error::NotFound);
164        }
165
166        Ok(response.bytes_stream())
167    }
168
169    // A URL the client fetches from the bucket directly, so the bytes never
170    // cross this server. Whether the caller is entitled to them has already been
171    // settled by the marker before this is called: the signature is scoped to
172    // one content key and expires, and it grants nothing the batch response was
173    // not about to grant anyway.
174    pub fn presigned_download(&self, oid: &str) -> Option<String> {
175        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
176            return None;
177        }
178
179        let key = Self::content_key(oid);
180
181        Some(
182            GetObject::new(&self.bucket, Some(&self.credentials), &key)
183                .sign(self.lifetime)
184                .to_string(),
185        )
186    }
187
188    async fn put(&self, key: &str, body: reqwest::Body, length: u64) -> Result<(), Error> {
189        let action = PutObject::new(&self.bucket, Some(&self.credentials), key);
190        let url = action.sign(self.lifetime);
191
192        let response = self
193            .client
194            .put(url)
195            // S3 has no use for a chunked body and answers 501 rather than
196            // starting the upload. reqwest cannot infer a length from a stream,
197            // so it comes from the staging file being sent.
198            .header(reqwest::header::CONTENT_LENGTH, length)
199            .body(body)
200            .send()
201            .await
202            .map_err(|_| {
203                Error::Storage(std::io::Error::other("the object store is unreachable"))
204            })?;
205
206        let status = response.status();
207        if !status.is_success() {
208            // The store says why in the body, and an operator staring at a
209            // failing push has nothing else to go on: a bucket that does not
210            // exist, a key that is denied and a clock that has drifted are three
211            // different afternoons.
212            let detail = response.text().await.unwrap_or_default();
213
214            return Err(Error::Storage(std::io::Error::other(format!(
215                "the object store refused a write with {status}: {}",
216                detail.trim()
217            ))));
218        }
219
220        Ok(())
221    }
222
223    // The upload has already been streamed to a staging file, hashed and checked
224    // against everything the server enforces, so that file is what goes up —
225    // streamed from disk rather than read into memory, because an object here is
226    // measured in gigabytes and the whole storage layer is built on holding at
227    // most a few megabytes of one at a time.
228    //
229    // The bytes go up once, keyed by their digest, and the marker records that
230    // this repository holds them. Content that is already there is skipped: the
231    // key would receive the same bytes it already has.
232    pub async fn store(
233        &self,
234        ns: &Namespace,
235        oid: &str,
236        staged: &std::path::Path,
237    ) -> Result<(), Error> {
238        crate::storage::LocalStore::validate_oid(oid)?;
239
240        if self.head(&Self::content_key(oid)).await.is_err() {
241            let file = tokio::fs::File::open(staged).await?;
242            let length = file.metadata().await?.len();
243            let stream = tokio_util::io::ReaderStream::new(file);
244
245            self.put(
246                &Self::content_key(oid),
247                reqwest::Body::wrap_stream(stream),
248                length,
249            )
250            .await?;
251        }
252
253        self.put(
254            &Self::marker_key(ns, oid),
255            reqwest::Body::from(Vec::new()),
256            0,
257        )
258        .await
259    }
260
261    // What the bucket holds for this repository, counted from its markers and
262    // the content they point at. The markers are empty, so their own size says
263    // nothing — this is a listing plus one head per object, which is why the
264    // figure is cached the same way the local one is.
265    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
266        let prefix = format!("{}/{}/", ns.org(), ns.repo());
267        let mut objects = 0;
268        let mut bytes = 0;
269
270        for oid in self.list(&prefix).await {
271            objects += 1;
272            bytes += self.size_of(&oid).await.unwrap_or_default();
273        }
274
275        (objects, bytes)
276    }
277
278    async fn list(&self, prefix: &str) -> Vec<String> {
279        let mut action = ListObjectsV2::new(&self.bucket, Some(&self.credentials));
280        action.with_prefix(prefix);
281
282        // Every step logs what stopped it rather than returning an empty
283        // listing: a capacity figure that silently reads zero is worse than one
284        // that is missing, because it looks like an answer.
285        let response = match self.client.get(action.sign(self.lifetime)).send().await {
286            Ok(response) => response,
287            Err(error) => {
288                tracing::warn!(%error, "the object store could not be listed");
289                return Vec::new();
290            }
291        };
292
293        let body = match response.text().await {
294            Ok(body) => body,
295            Err(error) => {
296                tracing::warn!(%error, "the listing could not be read");
297                return Vec::new();
298            }
299        };
300
301        let listing = match ListObjectsV2::parse_response(&body) {
302            Ok(listing) => listing,
303            Err(error) => {
304                tracing::warn!(%error, "the listing could not be parsed");
305                return Vec::new();
306            }
307        };
308
309        listing
310            .contents
311            .into_iter()
312            .filter_map(|object| object.key.rsplit('/').next().map(str::to_owned))
313            .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
314            .collect()
315    }
316}
317
318#[cfg(test)]
319pub(crate) mod tests;