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;