Skip to main content

lfsx_server/storage/
mod.rs

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