Skip to main content

lfsx_server/storage/
mod.rs

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