Skip to main content

lfsx_server/storage/
s3.rs

1pub(crate) mod keyspace;
2pub(crate) mod refs;
3
4use std::time::Duration;
5
6use axum::body::Bytes;
7use futures_util::Stream;
8
9use base64::Engine;
10
11use crate::error::Error;
12use crate::namespace::Namespace;
13use crate::storage::Reclaimed;
14
15pub use keyspace::{Keyspace, Presigned};
16
17const CHECKSUM: &str = "x-amz-checksum-sha256";
18
19pub struct S3Config {
20    pub endpoint: String,
21    pub bucket: String,
22    pub region: String,
23    pub access_key: String,
24    pub secret_key: String,
25    pub path_style: bool,
26    // How long a signature is good for. It is the same number the batch
27    // response advertises as `expires_in`, because a client told it has half an
28    // hour and handed a URL that dies in five minutes will fail a resume it had
29    // every reason to expect to work.
30    pub lifetime: Duration,
31}
32
33// The same layout as the local store, for the same reasons. The bytes live once
34// under a key derived from their digest, and a repository that holds them owns
35// an empty marker beside it — the object store's answer to a hard link. It is
36// what keeps two projects sharing an asset pack from paying twice, and what
37// stops a repository reading an object it never pushed: the marker is the proof
38// of possession, and it is the only thing the permission check consults.
39//
40// Everything below is object semantics. What it takes to talk to the store at
41// all — signing, retrying, listing, the client — is the keyspace underneath.
42#[derive(Clone)]
43pub struct S3Store {
44    keys: Keyspace,
45    redirect: bool,
46}
47
48impl S3Store {
49    pub fn new(keys: Keyspace, redirect: bool) -> Self {
50        Self { keys, redirect }
51    }
52
53    fn content_key(oid: &str) -> String {
54        format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
55    }
56
57    // Where a client uploads to when the bytes never pass through this server.
58    // Per repository on purpose: the shared content key would take bytes from
59    // anyone allowed to write, and then nothing distinguishes a repository that
60    // uploaded an object from one that merely knew its digest. A key only this
61    // repository was handed a signature for is the proof of possession that the
62    // marker stands for everywhere else.
63    fn incoming_key(ns: &Namespace, oid: &str) -> String {
64        format!(
65            ".incoming/{}/{}/{}/{}/{oid}",
66            ns.org(),
67            ns.repo(),
68            &oid[0..2],
69            &oid[2..4]
70        )
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    fn own_prefix(ns: &Namespace) -> String {
84        format!("{}/{}/", ns.org(), ns.repo())
85    }
86
87    pub async fn reachable(&self) -> Result<(), Error> {
88        self.keys.reachable().await
89    }
90
91    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
92        if crate::storage::LocalStore::validate_oid(oid).is_err() {
93            return false;
94        }
95
96        self.keys.head(&Self::marker_key(ns, oid)).await.is_ok()
97    }
98
99    pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
100        // Every entry point validates before slicing an oid into a key: the
101        // fanout takes the first four characters, so a short one is a panic
102        // rather than a refusal, and a panic is a 500 for something that should
103        // have been a 422.
104        crate::storage::LocalStore::validate_oid(oid)?;
105
106        self.keys.head(&Self::content_key(oid)).await
107    }
108
109    // A download is streamed through this server rather than redirected, so the
110    // features that live in the byte path — the counters, the ranges, and the
111    // compression that will follow — keep working. The pre-signed redirect is a
112    // separate mode for operators who would rather spend the object store's
113    // bandwidth than their own.
114    pub async fn read(
115        &self,
116        oid: &str,
117        start: u64,
118        length: u64,
119    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
120        crate::storage::LocalStore::validate_oid(oid)?;
121
122        self.keys
123            .get_range(&Self::content_key(oid), start, length)
124            .await
125    }
126
127    // A URL the client fetches from the bucket directly, so the bytes never
128    // cross this server. Whether the caller is entitled to them has already been
129    // settled by the marker before this is called: the signature is scoped to
130    // one content key and expires, and it grants nothing the batch response was
131    // not about to grant anyway.
132    pub fn presigned_download(&self, oid: &str) -> Option<String> {
133        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
134            return None;
135        }
136
137        Some(self.keys.signed_download(&Self::content_key(oid)))
138    }
139
140    // A URL the client PUTs the object to, and the headers it has to send with
141    // it. The digest is bound into the signature, so the store refuses anything
142    // that does not hash to the object it was signed for: a client with this URL
143    // cannot put arbitrary bytes anywhere, which is what makes handing one out
144    // safe at all.
145    pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<Presigned> {
146        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
147            return None;
148        }
149
150        let digest = base64::engine::general_purpose::STANDARD.encode(hex::decode(oid).ok()?);
151
152        Some(self.keys.signed_upload(
153            &Self::incoming_key(ns, oid),
154            vec![(CHECKSUM.to_owned(), digest)],
155        ))
156    }
157
158    // How big the object a client uploaded actually is, which is the first thing
159    // this server learns about it: nothing measured the bytes on the way past.
160    pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<u64, Error> {
161        crate::storage::LocalStore::validate_oid(oid)?;
162
163        self.keys.head(&Self::incoming_key(ns, oid)).await
164    }
165
166    // Take an upload that landed under this repository's own key into the shared
167    // keyspace. The bytes are already known to hash to the oid, because the store
168    // refused everything else.
169    pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
170        crate::storage::LocalStore::validate_oid(oid)?;
171
172        let incoming = Self::incoming_key(ns, oid);
173        let content = Self::content_key(oid);
174
175        // Already there means another repository pushed the same object, and the
176        // bytes are identical by construction.
177        if self.keys.head(&content).await.is_err() {
178            self.keys.copy(&incoming, &content).await?;
179        }
180
181        // Before the marker, always. The marker is the claim and the ref is the
182        // index of it, so a crash between the two has to leave a ref nobody
183        // claims rather than a claim nothing indexes: the first leaks an object,
184        // the second lets a later sweep free bytes this repository holds.
185        refs::write(&self.keys, ns, oid).await?;
186
187        self.keys
188            .put(
189                &Self::marker_key(ns, oid),
190                reqwest::Body::from(Vec::new()),
191                0,
192            )
193            .await?;
194
195        // Leaving it would pay for the object twice. A failure here is not worth
196        // failing the push over: the object is adopted, and what is left is a key
197        // the operator can see.
198        if let Err(error) = self.keys.delete(&incoming).await {
199            tracing::warn!(%error, key = incoming, "an adopted upload could not be cleaned up");
200        }
201
202        Ok(())
203    }
204
205    // The upload has already been streamed to a staging file, hashed and checked
206    // against everything the server enforces, so that file is what goes up —
207    // streamed from disk rather than read into memory, because an object here is
208    // measured in gigabytes and the whole storage layer is built on holding at
209    // most a few megabytes of one at a time.
210    //
211    // The bytes go up once, keyed by their digest, and the marker records that
212    // this repository holds them. Content that is already there is skipped: the
213    // key would receive the same bytes it already has.
214    pub async fn store(
215        &self,
216        ns: &Namespace,
217        oid: &str,
218        staged: &std::path::Path,
219    ) -> Result<(), Error> {
220        crate::storage::LocalStore::validate_oid(oid)?;
221
222        if self.keys.head(&Self::content_key(oid)).await.is_err() {
223            let file = tokio::fs::File::open(staged).await?;
224            let length = file.metadata().await?.len();
225            let stream = tokio_util::io::ReaderStream::new(file);
226
227            self.keys
228                .put(
229                    &Self::content_key(oid),
230                    reqwest::Body::wrap_stream(stream),
231                    length,
232                )
233                .await?;
234        }
235
236        refs::write(&self.keys, ns, oid).await?;
237
238        self.keys
239            .put(
240                &Self::marker_key(ns, oid),
241                reqwest::Body::from(Vec::new()),
242                0,
243            )
244            .await
245    }
246
247    // What an interrupted upload leaves behind. A client can negotiate, PUT the
248    // object, and never report it: the bytes sit under its own upload key and
249    // nothing else will ever look at them. The local path has had a reclaimer for
250    // this since the beginning, and a bucket had none, so the cost was unbounded
251    // over time and invisible.
252    pub async fn reclaim_incoming(&self, older_than: Duration) -> Result<Reclaimed, Error> {
253        let mut reclaimed = Reclaimed::default();
254
255        for entry in self.keys.entries(".incoming/").await? {
256            // A slow client on a bad connection is not an abandoned one.
257            if entry.age().is_none_or(|age| age < older_than) {
258                continue;
259            }
260
261            if self.keys.delete(&entry.key).await.is_ok() {
262                reclaimed.files += 1;
263                reclaimed.bytes += entry.size;
264            }
265        }
266
267        Ok(reclaimed)
268    }
269
270    // Collection, with the marker keyspace standing in for the link count a
271    // filesystem keeps. A repository's marker is its claim on the bytes, and the
272    // bytes go when the last claim does.
273    //
274    // Everything hard here is one question: does any *other* repository still
275    // claim this object? A marker is `{org}/{repo}/.../{oid}`, so the oid is the
276    // suffix and the org and repo that would make a prefix are exactly what is
277    // unknown. The claim index turns that into one prefix listing per object. A
278    // bucket that predates the index has to be read whole instead, and that pass
279    // builds the index as it goes, so it is paid once rather than every sweep.
280    pub async fn sweep(
281        &self,
282        ns: &Namespace,
283        retained: &std::collections::HashSet<String>,
284        grace: Duration,
285        dry_run: bool,
286    ) -> Result<crate::storage::SweepReport, Error> {
287        if refs::ready(&self.keys).await {
288            self.sweep_indexed(ns, retained, grace, dry_run).await
289        } else {
290            self.sweep_whole_bucket(ns, retained, grace, dry_run).await
291        }
292    }
293
294    // The markers this repository is allowed to drop. Retained is what the client
295    // says it still needs; the grace window is what keeps a push still in flight
296    // from being read as an abandoned object.
297    fn droppable(
298        mine: Vec<(keyspace::Entry, String)>,
299        retained: &std::collections::HashSet<String>,
300        grace: Duration,
301        report: &mut crate::storage::SweepReport,
302    ) -> Vec<(keyspace::Entry, String)> {
303        mine.into_iter()
304            .filter(|(entry, oid)| {
305                if retained.contains(oid) {
306                    return false;
307                }
308
309                if entry.age().is_none_or(|age| age < grace) {
310                    report.within_grace += 1;
311                    return false;
312                }
313
314                report.swept += 1;
315                true
316            })
317            .collect()
318    }
319
320    // The cost this exists to avoid: one listing of this repository's own prefix,
321    // then one listing of a short index prefix per object actually being dropped.
322    // Nothing here is proportional to the size of the bucket.
323    async fn sweep_indexed(
324        &self,
325        ns: &Namespace,
326        retained: &std::collections::HashSet<String>,
327        grace: Duration,
328        dry_run: bool,
329    ) -> Result<crate::storage::SweepReport, Error> {
330        let listing = self.keys.listing(&Self::own_prefix(ns)).await;
331        let mut report = crate::storage::SweepReport {
332            dry_run,
333            incomplete: !listing.complete,
334            ..Default::default()
335        };
336
337        let mine = listing
338            .entries
339            .into_iter()
340            .filter_map(|entry| {
341                let oid = entry.key.rsplit('/').next()?.to_owned();
342                crate::storage::LocalStore::validate_oid(&oid).ok()?;
343                Some((entry, oid))
344            })
345            .collect();
346
347        for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
348            let frees = !refs::claimed_by_another(&self.keys, ns, &oid).await;
349
350            if dry_run {
351                if frees {
352                    report.bytes += self.size_of(&oid).await.unwrap_or_default();
353                }
354                continue;
355            }
356
357            self.keys.delete(&entry.key).await?;
358
359            // After the marker, never before. A failure between the two has to
360            // leave a ref with no claim behind it, which costs an object nobody
361            // reads, rather than a claim with no ref, which would let the next
362            // sweep free bytes this repository still holds.
363            if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
364                tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
365            }
366
367            if frees {
368                // Asked before the delete, because afterwards there is nothing
369                // left to ask.
370                let size = self.size_of(&oid).await.unwrap_or_default();
371
372                if self.keys.delete(&Self::content_key(&oid)).await? {
373                    report.bytes += size;
374                }
375            }
376        }
377
378        Ok(report)
379    }
380
381    // What a bucket with no index costs, and what builds one.
382    //
383    // One listing of the whole bucket answers all three questions at once: which
384    // markers this repository holds, which oids any other repository still
385    // claims, and how big each content object is. Asked separately they would
386    // cost a request per object, which on a bucket is the difference between a
387    // collection an operator runs and one they read about.
388    //
389    // A listing that did not finish is the dangerous case. It cannot be used to
390    // conclude that nothing references an object, because the reference may sit
391    // in the pages that never arrived. So an incomplete listing still drops this
392    // repository's markers, which the retained set alone decides, and leaves
393    // every content key exactly where it is.
394    async fn sweep_whole_bucket(
395        &self,
396        ns: &Namespace,
397        retained: &std::collections::HashSet<String>,
398        grace: Duration,
399        dry_run: bool,
400    ) -> Result<crate::storage::SweepReport, Error> {
401        let listing = self.keys.listing("").await;
402        let mut report = crate::storage::SweepReport {
403            dry_run,
404            incomplete: !listing.complete,
405            ..Default::default()
406        };
407
408        let ours = Self::own_prefix(ns);
409        let mut markers = Vec::new();
410        let mut mine = Vec::new();
411        let mut claimed_elsewhere = std::collections::HashSet::new();
412        let mut sizes = std::collections::HashMap::new();
413
414        for entry in listing.entries {
415            if let Some(rest) = entry.key.strip_prefix(".content/") {
416                if let Some(oid) = rest.rsplit('/').next() {
417                    sizes.insert(oid.to_owned(), entry.size);
418                }
419                continue;
420            }
421
422            // Locks live at `.locks/{org}/{repo}/{id}`, so they never match the
423            // marker prefix and are never swept. Skipped explicitly all the same:
424            // falling through would file every lock id in the claimed set, and an
425            // object whose digest happened to equal a lock id would then never be
426            // collected. The odds are absurd today and the line costs nothing,
427            // but the code should not depend on ids and digests never colliding.
428            //
429            // The index is skipped for a sharper reason than caution:
430            // `.refs/{oid}/{org}/{repo}` ends in a repository name, so reading one
431            // as a marker would file that name as an oid somebody claims.
432            if entry.key.starts_with(".incoming/")
433                || entry.key.starts_with(".locks/")
434                || entry.key.starts_with(".refs/")
435            {
436                continue;
437            }
438
439            let Some(oid) = entry.key.rsplit('/').next().map(str::to_owned) else {
440                continue;
441            };
442
443            markers.push(entry.key.clone());
444
445            if entry.key.starts_with(&ours) {
446                mine.push((entry, oid));
447            } else {
448                claimed_elsewhere.insert(oid);
449            }
450        }
451
452        // Before anything is deleted, so the index never gains a ref for a marker
453        // this sweep is about to drop. Built from the listing already paid for,
454        // and only when that listing finished: an index built from half a bucket
455        // would be missing holders, which is the one direction it must never
456        // drift in.
457        //
458        // A failure is not fatal. The listing above has already answered the
459        // question correctly on its own, so collection proceeds and the next
460        // sweep reads the bucket again.
461        if !dry_run
462            && listing.complete
463            && let Err(error) = refs::backfill(&self.keys, &markers).await
464        {
465            tracing::warn!(
466                %error,
467                "the claim index could not be built, so the next sweep reads the bucket again"
468            );
469        }
470
471        for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
472            // Only what this call actually frees is counted. Another repository
473            // holding the same bytes means dropping this marker frees nothing,
474            // and a dry run that said otherwise would promise space it cannot
475            // deliver.
476            let frees = listing.complete && !claimed_elsewhere.contains(&oid);
477            let size = sizes.get(&oid).copied().unwrap_or_default();
478
479            if dry_run {
480                if frees {
481                    report.bytes += size;
482                }
483                continue;
484            }
485
486            self.keys.delete(&entry.key).await?;
487
488            if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
489                tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
490            }
491
492            // Counted only when this call is the one that removed them, so two
493            // repositories letting go at once cannot each claim the same space.
494            if frees && self.keys.delete(&Self::content_key(&oid)).await? {
495                report.bytes += size;
496            }
497        }
498
499        Ok(report)
500    }
501
502    // What the bucket holds for this repository, counted from its markers and
503    // the content they point at. The markers are empty, so their own size says
504    // nothing — this is a listing plus one head per object, which is why the
505    // figure is cached the same way the local one is.
506    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
507        let prefix = Self::own_prefix(ns);
508        let mut objects = 0;
509        let mut bytes = 0;
510
511        for oid in self.list(&prefix).await {
512            objects += 1;
513            bytes += self.size_of(&oid).await.unwrap_or_default();
514        }
515
516        (objects, bytes)
517    }
518
519    async fn list(&self, prefix: &str) -> Vec<String> {
520        // A capacity figure that silently reads zero is worse than one that is
521        // missing, because it looks like an answer.
522        let keys = match self.keys.keys(prefix).await {
523            Ok(keys) => keys,
524            Err(error) => {
525                tracing::warn!(%error, "the object store could not be listed");
526                return Vec::new();
527            }
528        };
529
530        keys.into_iter()
531            .filter_map(|key| key.rsplit('/').next().map(str::to_owned))
532            .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
533            .collect()
534    }
535}
536
537#[cfg(test)]
538pub(crate) mod tests;