Skip to main content

lfsx_server/storage/
mod.rs

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