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