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