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