Skip to main content

lfsx_server/storage/
mod.rs

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