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#[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 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 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 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 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 Err(_) => fs::copy(content, final_path).await.map(|_| ()),
299 }
300 }
301}