Skip to main content

lfsx_server/storage/
s3.rs

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