Skip to main content

lfsx_server/storage/
s3.rs

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