Skip to main content

lfsx_server/storage/
s3.rs

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