Skip to main content

lfsx_server/storage/
mod.rs

1mod backend;
2mod codec;
3mod dedupe;
4mod rewrite;
5pub mod s3;
6mod staging;
7mod sweep;
8mod verify;
9
10#[cfg(test)]
11mod tests;
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Instant;
17
18use tokio::sync::Mutex;
19
20use futures_util::{Stream, StreamExt};
21use sha2::{Digest, Sha256};
22use tokio::fs;
23use tokio::io::AsyncWriteExt;
24
25use crate::error::Error;
26use crate::namespace::Namespace;
27
28pub use backend::Store;
29pub use dedupe::DedupeReport;
30use dedupe::shares_bytes_with;
31pub use rewrite::CompressReport;
32pub use verify::VerifyReport;
33
34enum Sink {
35    Raw(fs::File),
36    Framed(Box<codec::Writer>),
37}
38
39impl Sink {
40    async fn write(&mut self, chunk: &[u8]) -> Result<(), Error> {
41        match self {
42            Self::Raw(file) => Ok(file.write_all(chunk).await?),
43            Self::Framed(writer) => writer.push(chunk).await,
44        }
45    }
46
47    async fn finish(self) -> Result<(), Error> {
48        match self {
49            Self::Raw(mut file) => {
50                file.flush().await?;
51                Ok(file.sync_all().await?)
52            }
53            Self::Framed(writer) => writer.finish().await,
54        }
55    }
56}
57
58// A transfer that has passed every check and is waiting to be put somewhere.
59pub struct Staged {
60    pub path: PathBuf,
61    destination: PathBuf,
62    pub written: u64,
63    fresh: bool,
64}
65
66// What a download reads from, whether or not the bytes on disk are the object.
67pub enum Object {
68    Raw {
69        file: fs::File,
70        size: u64,
71    },
72    Framed(codec::Framed),
73    Remote {
74        bucket: s3::S3Store,
75        oid: String,
76        size: u64,
77    },
78}
79
80impl Object {
81    pub fn size(&self) -> u64 {
82        match self {
83            Self::Raw { size, .. } => *size,
84            Self::Framed(framed) => framed.plaintext(),
85            Self::Remote { size, .. } => *size,
86        }
87    }
88
89    pub async fn stream(
90        self,
91        start: u64,
92        length: u64,
93    ) -> Result<futures_util::stream::BoxStream<'static, Result<axum::body::Bytes, Error>>, Error>
94    {
95        use futures_util::StreamExt;
96        use tokio::io::AsyncSeekExt;
97
98        match self {
99            Self::Raw { mut file, .. } => {
100                file.seek(std::io::SeekFrom::Start(start)).await?;
101                let reader =
102                    tokio_util::io::ReaderStream::new(tokio::io::AsyncReadExt::take(file, length));
103
104                Ok(reader.map(|chunk| chunk.map_err(Error::from)).boxed())
105            }
106            Self::Framed(framed) => Ok(framed.stream(start, length).boxed()),
107            Self::Remote { bucket, oid, .. } => {
108                let chunks = bucket.read(&oid, start, length).await?;
109
110                Ok(chunks
111                    .map(|chunk| {
112                        chunk.map_err(|error| Error::Storage(std::io::Error::other(error)))
113                    })
114                    .boxed())
115            }
116        }
117    }
118}
119pub use staging::{Reclaimed, reclaim};
120pub use sweep::SweepReport;
121
122// What is left of a repository's budget for one transfer. It travels with the
123// upload because a client that skips negotiation may also skip declaring a
124// size, and a budget checked once against a number the client chose is not a
125// budget.
126#[derive(Debug, Clone, Copy)]
127pub struct Budget {
128    pub used: u64,
129    pub limit: u64,
130}
131
132impl Budget {
133    pub fn exceeded_by(&self, arriving: u64) -> bool {
134        self.used + arriving > self.limit
135    }
136
137    pub fn refusal(&self) -> Error {
138        Error::OverQuota {
139            used: self.used,
140            limit: self.limit,
141        }
142    }
143}
144
145pub struct LocalStore {
146    root: PathBuf,
147    counter: AtomicU64,
148    usage: Mutex<Option<(Instant, u64, u64)>>,
149    per_namespace: Mutex<HashMap<String, (Instant, u64, u64)>>,
150    scans: AtomicU64,
151    max_object_size: Option<u64>,
152    compression: Option<i32>,
153}
154
155impl LocalStore {
156    pub fn new(root: impl Into<PathBuf>) -> Self {
157        Self {
158            root: root.into(),
159            counter: AtomicU64::new(0),
160            usage: Mutex::new(None),
161            per_namespace: Mutex::new(HashMap::new()),
162            scans: AtomicU64::new(0),
163            max_object_size: None,
164            compression: None,
165        }
166    }
167
168    pub fn with_compression(mut self, level: Option<i32>) -> Self {
169        self.compression = level;
170        self
171    }
172
173    pub fn with_max_object_size(mut self, limit: Option<u64>) -> Self {
174        self.max_object_size = limit;
175        self
176    }
177
178    pub fn validate_oid(oid: &str) -> Result<(), Error> {
179        let well_formed = oid.len() == 64
180            && oid
181                .bytes()
182                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
183
184        well_formed.then_some(()).ok_or(Error::MalformedOid)
185    }
186
187    fn object_path(&self, ns: &Namespace, oid: &str) -> PathBuf {
188        self.root
189            .join(ns.org())
190            .join(ns.repo())
191            .join(&oid[0..2])
192            .join(&oid[2..4])
193            .join(oid)
194    }
195
196    fn content_path(&self, oid: &str) -> PathBuf {
197        self.root
198            .join(".content")
199            .join(&oid[0..2])
200            .join(&oid[2..4])
201            .join(oid)
202    }
203
204    pub fn scans(&self) -> u64 {
205        self.scans.load(Ordering::Relaxed)
206    }
207
208    pub async fn writable(&self) -> Result<(), Error> {
209        fs::create_dir_all(&self.root).await?;
210
211        let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
212        let probe = self.root.join(format!(".readiness.{ticket}"));
213
214        fs::write(&probe, b"").await?;
215        fs::remove_file(&probe).await?;
216
217        Ok(())
218    }
219
220    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
221        Self::validate_oid(oid).is_ok() && fs::metadata(self.object_path(ns, oid)).await.is_ok()
222    }
223
224    pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
225        Self::validate_oid(oid)?;
226        let path = self.object_path(ns, oid);
227        let file = fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
228        let on_disk = file.metadata().await?.len();
229
230        match codec::Framed::open(file, on_disk).await? {
231            Some(framed) => Ok(Object::Framed(framed)),
232            None => Ok(Object::Raw {
233                file: fs::File::open(&path).await.map_err(|_| Error::NotFound)?,
234                size: on_disk,
235            }),
236        }
237    }
238
239    // Everything a transfer has to survive before it counts as an object: the
240    // digest it claims, the size it declared, the ceiling on a single object and
241    // the repository's remaining budget. It ends on local disk whatever the
242    // backend is, because a bucket cannot be asked to hold bytes that might turn
243    // out to be the wrong ones.
244    pub async fn stage<S, E>(
245        &self,
246        ns: &Namespace,
247        oid: &str,
248        expected_size: Option<u64>,
249        budget: Option<Budget>,
250        mut chunks: S,
251    ) -> Result<Staged, Error>
252    where
253        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
254        E: std::error::Error + Send + Sync + 'static,
255    {
256        Self::validate_oid(oid)?;
257
258        if let Some(limit) = self.max_object_size
259            && expected_size.is_some_and(|declared| declared > limit)
260        {
261            return Err(Error::TooLarge { limit });
262        }
263
264        let path = self.object_path(ns, oid);
265        let parent = path.parent().expect("object paths always have a parent");
266        fs::create_dir_all(parent).await?;
267
268        // A retried transfer of an object this repository already holds costs it
269        // no room, so it must not count against the budget a second time.
270        let fresh = fs::metadata(&path).await.is_err();
271        let staged = self.staging_path(parent, oid);
272
273        match self.stream_to(&staged, budget, &mut chunks).await {
274            Ok((digest, written)) => {
275                if let Err(error) = Self::agrees(oid, expected_size, &digest, written) {
276                    let _ = fs::remove_file(&staged).await;
277                    return Err(error);
278                }
279
280                Ok(Staged {
281                    path: staged,
282                    destination: path,
283                    written,
284                    fresh,
285                })
286            }
287            Err(error) => {
288                let _ = fs::remove_file(&staged).await;
289                Err(error)
290            }
291        }
292    }
293
294    fn agrees(
295        oid: &str,
296        expected_size: Option<u64>,
297        digest: &str,
298        written: u64,
299    ) -> Result<(), Error> {
300        if let Some(declared) = expected_size.filter(|declared| *declared != written) {
301            return Err(Error::SizeMismatch {
302                declared,
303                actual: written,
304            });
305        }
306
307        if digest != oid {
308            return Err(Error::OidMismatch {
309                declared: oid.to_owned(),
310                actual: digest.to_owned(),
311            });
312        }
313
314        Ok(())
315    }
316
317    pub async fn write<S, E>(
318        &self,
319        ns: &Namespace,
320        oid: &str,
321        expected_size: Option<u64>,
322        budget: Option<Budget>,
323        chunks: S,
324    ) -> Result<u64, Error>
325    where
326        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
327        E: std::error::Error + Send + Sync + 'static,
328    {
329        let staged = self.stage(ns, oid, expected_size, budget, chunks).await?;
330
331        self.link_or_move(&staged.path, &staged.destination, oid)
332            .await?;
333
334        if staged.fresh {
335            self.stored(ns, staged.written).await;
336        }
337
338        Ok(staged.written)
339    }
340
341    fn staging_path(&self, parent: &Path, oid: &str) -> PathBuf {
342        let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
343        parent.join(format!("{oid}.{ticket}.part"))
344    }
345
346    async fn stream_to<S, E>(
347        &self,
348        staged: &Path,
349        budget: Option<Budget>,
350        chunks: &mut S,
351    ) -> Result<(String, u64), Error>
352    where
353        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
354        E: std::error::Error + Send + Sync + 'static,
355    {
356        let file = fs::File::create(staged).await?;
357        // The digest, the declared size and the budget are all counted on the
358        // plaintext going past, whatever the bytes look like once they land —
359        // so compression is a different sink, not a different path.
360        let mut sink = match self.compression {
361            Some(level) => Sink::Framed(Box::new(codec::Writer::open(file, level).await?)),
362            None => Sink::Raw(file),
363        };
364        let mut hasher = Sha256::new();
365        let mut written = 0u64;
366
367        while let Some(chunk) = chunks.next().await {
368            let chunk = chunk.map_err(std::io::Error::other)?;
369            hasher.update(&chunk);
370            written += chunk.len() as u64;
371
372            // The declared size is a claim by the client, so the ceiling has to
373            // hold against a body that ignores it. Stopping at the chunk that
374            // crosses the line is the point: reading to the end to find out how
375            // big it was would be the outage this limit exists to prevent.
376            if let Some(limit) = self.max_object_size.filter(|limit| written > *limit) {
377                return Err(Error::TooLarge { limit });
378            }
379
380            if let Some(budget) = budget.filter(|budget| budget.exceeded_by(written)) {
381                return Err(budget.refusal());
382            }
383
384            sink.write(&chunk).await?;
385        }
386
387        sink.finish().await?;
388
389        Ok((hex::encode(hasher.finalize()), written))
390    }
391
392    // One copy of the bytes under .content, and a hard link per repository that
393    // holds them. Two projects sharing an asset pack cost the disk once, and the
394    // link count is the reference count — the filesystem does the bookkeeping, so
395    // nothing can leak a repository's contents to another and nothing needs a
396    // migration: objects already sitting at their repository path keep working as
397    // ordinary files with one link.
398    async fn link_or_move(&self, staged: &Path, final_path: &Path, oid: &str) -> Result<(), Error> {
399        let content = self.content_path(oid);
400        let parent = content.parent().expect("content paths have a parent");
401        fs::create_dir_all(parent).await?;
402
403        if fs::metadata(&content).await.is_err() {
404            fs::rename(staged, &content).await?;
405        }
406
407        match self.link(&content, final_path).await {
408            // The content was collected between finding it and linking to it:
409            // a concurrent retain on another repository dropped its last other
410            // reference. The staged copy is still here precisely for this, so
411            // put it back and link again rather than failing a push that did
412            // nothing wrong.
413            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
414                fs::rename(staged, &content).await?;
415                self.link(&content, final_path).await?;
416            }
417            outcome => outcome?,
418        }
419
420        let _ = fs::remove_file(staged).await;
421        Ok(())
422    }
423
424    async fn link(&self, content: &Path, final_path: &Path) -> Result<(), std::io::Error> {
425        let from = content.to_path_buf();
426        let to = final_path.to_path_buf();
427        let linked = tokio::task::spawn_blocking(move || std::fs::hard_link(&from, &to))
428            .await
429            .map_err(std::io::Error::other)?;
430
431        match linked {
432            Ok(()) => Ok(()),
433            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
434            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(error),
435            // A filesystem without hard links, or one crossing a device
436            // boundary: fall back to a full copy so the transfer still
437            // succeeds. The disk pays for it, the client never notices.
438            Err(_) => fs::copy(content, final_path).await.map(|_| ()),
439        }
440    }
441}