Skip to main content

lfsx_server/storage/
mod.rs

1mod staging;
2mod sweep;
3
4#[cfg(test)]
5mod tests;
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::Instant;
11
12use tokio::sync::Mutex;
13
14use futures_util::{Stream, StreamExt};
15use sha2::{Digest, Sha256};
16use tokio::fs;
17use tokio::io::AsyncWriteExt;
18
19use crate::error::Error;
20use crate::namespace::Namespace;
21
22pub use staging::{Reclaimed, reclaim};
23pub use sweep::SweepReport;
24
25pub struct LocalStore {
26    root: PathBuf,
27    counter: AtomicU64,
28    usage: Mutex<Option<(Instant, u64, u64)>>,
29    per_namespace: Mutex<HashMap<String, (Instant, u64, u64)>>,
30    scans: AtomicU64,
31    max_object_size: Option<u64>,
32}
33
34impl LocalStore {
35    pub fn new(root: impl Into<PathBuf>) -> Self {
36        Self {
37            root: root.into(),
38            counter: AtomicU64::new(0),
39            usage: Mutex::new(None),
40            per_namespace: Mutex::new(HashMap::new()),
41            scans: AtomicU64::new(0),
42            max_object_size: None,
43        }
44    }
45
46    pub fn with_max_object_size(mut self, limit: Option<u64>) -> Self {
47        self.max_object_size = limit;
48        self
49    }
50
51    pub fn validate_oid(oid: &str) -> Result<(), Error> {
52        let well_formed = oid.len() == 64
53            && oid
54                .bytes()
55                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
56
57        well_formed.then_some(()).ok_or(Error::MalformedOid)
58    }
59
60    fn object_path(&self, ns: &Namespace, oid: &str) -> PathBuf {
61        self.root
62            .join(ns.org())
63            .join(ns.repo())
64            .join(&oid[0..2])
65            .join(&oid[2..4])
66            .join(oid)
67    }
68
69    fn content_path(&self, oid: &str) -> PathBuf {
70        self.root
71            .join(".content")
72            .join(&oid[0..2])
73            .join(&oid[2..4])
74            .join(oid)
75    }
76
77    pub fn scans(&self) -> u64 {
78        self.scans.load(Ordering::Relaxed)
79    }
80
81    pub async fn writable(&self) -> Result<(), Error> {
82        fs::create_dir_all(&self.root).await?;
83
84        let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
85        let probe = self.root.join(format!(".readiness.{ticket}"));
86
87        fs::write(&probe, b"").await?;
88        fs::remove_file(&probe).await?;
89
90        Ok(())
91    }
92
93    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
94        Self::validate_oid(oid).is_ok() && fs::metadata(self.object_path(ns, oid)).await.is_ok()
95    }
96
97    pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<(fs::File, u64), Error> {
98        Self::validate_oid(oid)?;
99        let path = self.object_path(ns, oid);
100        let file = fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
101        let size = file.metadata().await?.len();
102        Ok((file, size))
103    }
104
105    pub async fn write<S, E>(
106        &self,
107        ns: &Namespace,
108        oid: &str,
109        expected_size: Option<u64>,
110        mut chunks: S,
111    ) -> Result<u64, Error>
112    where
113        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
114        E: std::error::Error + Send + Sync + 'static,
115    {
116        Self::validate_oid(oid)?;
117
118        if let Some(limit) = self.max_object_size
119            && expected_size.is_some_and(|declared| declared > limit)
120        {
121            return Err(Error::TooLarge { limit });
122        }
123
124        let path = self.object_path(ns, oid);
125        let parent = path.parent().expect("object paths always have a parent");
126        fs::create_dir_all(parent).await?;
127
128        let staged = self.staging_path(parent, oid);
129        let outcome = self.stream_to(&staged, &mut chunks).await;
130
131        match outcome {
132            Ok((digest, written)) => {
133                self.finish(&staged, &path, oid, expected_size, &digest, written)
134                    .await?;
135                Ok(written)
136            }
137            Err(error) => {
138                let _ = fs::remove_file(&staged).await;
139                Err(error)
140            }
141        }
142    }
143
144    fn staging_path(&self, parent: &Path, oid: &str) -> PathBuf {
145        let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
146        parent.join(format!("{oid}.{ticket}.part"))
147    }
148
149    async fn stream_to<S, E>(&self, staged: &Path, chunks: &mut S) -> Result<(String, u64), Error>
150    where
151        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
152        E: std::error::Error + Send + Sync + 'static,
153    {
154        let mut file = fs::File::create(staged).await?;
155        let mut hasher = Sha256::new();
156        let mut written = 0u64;
157
158        while let Some(chunk) = chunks.next().await {
159            let chunk = chunk.map_err(std::io::Error::other)?;
160            hasher.update(&chunk);
161            written += chunk.len() as u64;
162
163            // The declared size is a claim by the client, so the ceiling has to
164            // hold against a body that ignores it. Stopping at the chunk that
165            // crosses the line is the point: reading to the end to find out how
166            // big it was would be the outage this limit exists to prevent.
167            if let Some(limit) = self.max_object_size.filter(|limit| written > *limit) {
168                return Err(Error::TooLarge { limit });
169            }
170
171            file.write_all(&chunk).await?;
172        }
173
174        file.flush().await?;
175        file.sync_all().await?;
176
177        Ok((hex::encode(hasher.finalize()), written))
178    }
179
180    async fn finish(
181        &self,
182        staged: &Path,
183        final_path: &Path,
184        oid: &str,
185        expected_size: Option<u64>,
186        digest: &str,
187        written: u64,
188    ) -> Result<(), Error> {
189        if let Some(declared) = expected_size.filter(|declared| *declared != written) {
190            let _ = fs::remove_file(staged).await;
191            return Err(Error::SizeMismatch {
192                declared,
193                actual: written,
194            });
195        }
196
197        if digest != oid {
198            let _ = fs::remove_file(staged).await;
199            return Err(Error::OidMismatch {
200                declared: oid.to_owned(),
201                actual: digest.to_owned(),
202            });
203        }
204
205        self.link_or_move(staged, final_path, oid).await
206    }
207
208    // One copy of the bytes under .content, and a hard link per repository that
209    // holds them. Two projects sharing an asset pack cost the disk once, and the
210    // link count is the reference count — the filesystem does the bookkeeping, so
211    // nothing can leak a repository's contents to another and nothing needs a
212    // migration: objects already sitting at their repository path keep working as
213    // ordinary files with one link.
214    async fn link_or_move(&self, staged: &Path, final_path: &Path, oid: &str) -> Result<(), Error> {
215        let content = self.content_path(oid);
216        let parent = content.parent().expect("content paths have a parent");
217        fs::create_dir_all(parent).await?;
218
219        if fs::metadata(&content).await.is_err() {
220            fs::rename(staged, &content).await?;
221        }
222
223        match self.link(&content, final_path).await {
224            // The content was collected between finding it and linking to it:
225            // a concurrent retain on another repository dropped its last other
226            // reference. The staged copy is still here precisely for this, so
227            // put it back and link again rather than failing a push that did
228            // nothing wrong.
229            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
230                fs::rename(staged, &content).await?;
231                self.link(&content, final_path).await?;
232            }
233            outcome => outcome?,
234        }
235
236        let _ = fs::remove_file(staged).await;
237        Ok(())
238    }
239
240    async fn link(&self, content: &Path, final_path: &Path) -> Result<(), std::io::Error> {
241        let from = content.to_path_buf();
242        let to = final_path.to_path_buf();
243        let linked = tokio::task::spawn_blocking(move || std::fs::hard_link(&from, &to))
244            .await
245            .map_err(std::io::Error::other)?;
246
247        match linked {
248            Ok(()) => Ok(()),
249            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
250            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(error),
251            // A filesystem without hard links, or one crossing a device
252            // boundary: fall back to a full copy so the transfer still
253            // succeeds. The disk pays for it, the client never notices.
254            Err(_) => fs::copy(content, final_path).await.map(|_| ()),
255        }
256    }
257}