Skip to main content

lfsx_server/storage/
s3.rs

1pub(crate) mod keyspace;
2
3use std::time::Duration;
4
5use axum::body::Bytes;
6use futures_util::Stream;
7
8use base64::Engine;
9
10use crate::error::Error;
11use crate::namespace::Namespace;
12use crate::storage::Reclaimed;
13
14pub use keyspace::{Keyspace, Presigned};
15
16const CHECKSUM: &str = "x-amz-checksum-sha256";
17
18pub struct S3Config {
19    pub endpoint: String,
20    pub bucket: String,
21    pub region: String,
22    pub access_key: String,
23    pub secret_key: String,
24    pub path_style: bool,
25    // How long a signature is good for. It is the same number the batch
26    // response advertises as `expires_in`, because a client told it has half an
27    // hour and handed a URL that dies in five minutes will fail a resume it had
28    // every reason to expect to work.
29    pub lifetime: Duration,
30}
31
32// The same layout as the local store, for the same reasons. The bytes live once
33// under a key derived from their digest, and a repository that holds them owns
34// an empty marker beside it — the object store's answer to a hard link. It is
35// what keeps two projects sharing an asset pack from paying twice, and what
36// stops a repository reading an object it never pushed: the marker is the proof
37// of possession, and it is the only thing the permission check consults.
38//
39// Everything below is object semantics. What it takes to talk to the store at
40// all — signing, retrying, listing, the client — is the keyspace underneath.
41#[derive(Clone)]
42pub struct S3Store {
43    keys: Keyspace,
44    redirect: bool,
45}
46
47impl S3Store {
48    pub fn new(keys: Keyspace, redirect: bool) -> Self {
49        Self { keys, redirect }
50    }
51
52    fn content_key(oid: &str) -> String {
53        format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
54    }
55
56    // Where a client uploads to when the bytes never pass through this server.
57    // Per repository on purpose: the shared content key would take bytes from
58    // anyone allowed to write, and then nothing distinguishes a repository that
59    // uploaded an object from one that merely knew its digest. A key only this
60    // repository was handed a signature for is the proof of possession that the
61    // marker stands for everywhere else.
62    fn incoming_key(ns: &Namespace, oid: &str) -> String {
63        format!(
64            ".incoming/{}/{}/{}/{}/{oid}",
65            ns.org(),
66            ns.repo(),
67            &oid[0..2],
68            &oid[2..4]
69        )
70    }
71
72    fn marker_key(ns: &Namespace, oid: &str) -> String {
73        format!(
74            "{}/{}/{}/{}/{oid}",
75            ns.org(),
76            ns.repo(),
77            &oid[0..2],
78            &oid[2..4]
79        )
80    }
81
82    pub async fn reachable(&self) -> Result<(), Error> {
83        self.keys.reachable().await
84    }
85
86    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
87        if crate::storage::LocalStore::validate_oid(oid).is_err() {
88            return false;
89        }
90
91        self.keys.head(&Self::marker_key(ns, oid)).await.is_ok()
92    }
93
94    pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
95        // Every entry point validates before slicing an oid into a key: the
96        // fanout takes the first four characters, so a short one is a panic
97        // rather than a refusal, and a panic is a 500 for something that should
98        // have been a 422.
99        crate::storage::LocalStore::validate_oid(oid)?;
100
101        self.keys.head(&Self::content_key(oid)).await
102    }
103
104    // A download is streamed through this server rather than redirected, so the
105    // features that live in the byte path — the counters, the ranges, and the
106    // compression that will follow — keep working. The pre-signed redirect is a
107    // separate mode for operators who would rather spend the object store's
108    // bandwidth than their own.
109    pub async fn read(
110        &self,
111        oid: &str,
112        start: u64,
113        length: u64,
114    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
115        crate::storage::LocalStore::validate_oid(oid)?;
116
117        self.keys
118            .get_range(&Self::content_key(oid), start, length)
119            .await
120    }
121
122    // A URL the client fetches from the bucket directly, so the bytes never
123    // cross this server. Whether the caller is entitled to them has already been
124    // settled by the marker before this is called: the signature is scoped to
125    // one content key and expires, and it grants nothing the batch response was
126    // not about to grant anyway.
127    pub fn presigned_download(&self, oid: &str) -> Option<String> {
128        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
129            return None;
130        }
131
132        Some(self.keys.signed_download(&Self::content_key(oid)))
133    }
134
135    // A URL the client PUTs the object to, and the headers it has to send with
136    // it. The digest is bound into the signature, so the store refuses anything
137    // that does not hash to the object it was signed for: a client with this URL
138    // cannot put arbitrary bytes anywhere, which is what makes handing one out
139    // safe at all.
140    pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<Presigned> {
141        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
142            return None;
143        }
144
145        let digest = base64::engine::general_purpose::STANDARD.encode(hex::decode(oid).ok()?);
146
147        Some(self.keys.signed_upload(
148            &Self::incoming_key(ns, oid),
149            vec![(CHECKSUM.to_owned(), digest)],
150        ))
151    }
152
153    // How big the object a client uploaded actually is, which is the first thing
154    // this server learns about it: nothing measured the bytes on the way past.
155    pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<u64, Error> {
156        crate::storage::LocalStore::validate_oid(oid)?;
157
158        self.keys.head(&Self::incoming_key(ns, oid)).await
159    }
160
161    // Take an upload that landed under this repository's own key into the shared
162    // keyspace. The bytes are already known to hash to the oid, because the store
163    // refused everything else.
164    pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
165        crate::storage::LocalStore::validate_oid(oid)?;
166
167        let incoming = Self::incoming_key(ns, oid);
168        let content = Self::content_key(oid);
169
170        // Already there means another repository pushed the same object, and the
171        // bytes are identical by construction.
172        if self.keys.head(&content).await.is_err() {
173            self.keys.copy(&incoming, &content).await?;
174        }
175
176        self.keys
177            .put(
178                &Self::marker_key(ns, oid),
179                reqwest::Body::from(Vec::new()),
180                0,
181            )
182            .await?;
183
184        // Leaving it would pay for the object twice. A failure here is not worth
185        // failing the push over: the object is adopted, and what is left is a key
186        // the operator can see.
187        if let Err(error) = self.keys.delete(&incoming).await {
188            tracing::warn!(%error, key = incoming, "an adopted upload could not be cleaned up");
189        }
190
191        Ok(())
192    }
193
194    // The upload has already been streamed to a staging file, hashed and checked
195    // against everything the server enforces, so that file is what goes up —
196    // streamed from disk rather than read into memory, because an object here is
197    // measured in gigabytes and the whole storage layer is built on holding at
198    // most a few megabytes of one at a time.
199    //
200    // The bytes go up once, keyed by their digest, and the marker records that
201    // this repository holds them. Content that is already there is skipped: the
202    // key would receive the same bytes it already has.
203    pub async fn store(
204        &self,
205        ns: &Namespace,
206        oid: &str,
207        staged: &std::path::Path,
208    ) -> Result<(), Error> {
209        crate::storage::LocalStore::validate_oid(oid)?;
210
211        if self.keys.head(&Self::content_key(oid)).await.is_err() {
212            let file = tokio::fs::File::open(staged).await?;
213            let length = file.metadata().await?.len();
214            let stream = tokio_util::io::ReaderStream::new(file);
215
216            self.keys
217                .put(
218                    &Self::content_key(oid),
219                    reqwest::Body::wrap_stream(stream),
220                    length,
221                )
222                .await?;
223        }
224
225        self.keys
226            .put(
227                &Self::marker_key(ns, oid),
228                reqwest::Body::from(Vec::new()),
229                0,
230            )
231            .await
232    }
233
234    // What an interrupted upload leaves behind. A client can negotiate, PUT the
235    // object, and never report it: the bytes sit under its own upload key and
236    // nothing else will ever look at them. The local path has had a reclaimer for
237    // this since the beginning, and a bucket had none, so the cost was unbounded
238    // over time and invisible.
239    pub async fn reclaim_incoming(&self, older_than: Duration) -> Result<Reclaimed, Error> {
240        let mut reclaimed = Reclaimed::default();
241
242        for entry in self.keys.entries(".incoming/").await? {
243            // A slow client on a bad connection is not an abandoned one.
244            if entry.age().is_none_or(|age| age < older_than) {
245                continue;
246            }
247
248            if self.keys.delete(&entry.key).await.is_ok() {
249                reclaimed.files += 1;
250                reclaimed.bytes += entry.size;
251            }
252        }
253
254        Ok(reclaimed)
255    }
256
257    // What the bucket holds for this repository, counted from its markers and
258    // the content they point at. The markers are empty, so their own size says
259    // nothing — this is a listing plus one head per object, which is why the
260    // figure is cached the same way the local one is.
261    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
262        let prefix = format!("{}/{}/", ns.org(), ns.repo());
263        let mut objects = 0;
264        let mut bytes = 0;
265
266        for oid in self.list(&prefix).await {
267            objects += 1;
268            bytes += self.size_of(&oid).await.unwrap_or_default();
269        }
270
271        (objects, bytes)
272    }
273
274    async fn list(&self, prefix: &str) -> Vec<String> {
275        // A capacity figure that silently reads zero is worse than one that is
276        // missing, because it looks like an answer.
277        let keys = match self.keys.keys(prefix).await {
278            Ok(keys) => keys,
279            Err(error) => {
280                tracing::warn!(%error, "the object store could not be listed");
281                return Vec::new();
282            }
283        };
284
285        keys.into_iter()
286            .filter_map(|key| key.rsplit('/').next().map(str::to_owned))
287            .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
288            .collect()
289    }
290}
291
292#[cfg(test)]
293pub(crate) mod tests;