1mod codec;
2mod dedupe;
3mod staging;
4mod sweep;
5
6#[cfg(test)]
7mod tests;
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::Instant;
13
14use tokio::sync::Mutex;
15
16use futures_util::{Stream, StreamExt};
17use sha2::{Digest, Sha256};
18use tokio::fs;
19use tokio::io::AsyncWriteExt;
20
21use crate::error::Error;
22use crate::namespace::Namespace;
23
24pub use dedupe::DedupeReport;
25
26enum Sink {
27 Raw(fs::File),
28 Framed(Box<codec::Writer>),
29}
30
31impl Sink {
32 async fn write(&mut self, chunk: &[u8]) -> Result<(), Error> {
33 match self {
34 Self::Raw(file) => Ok(file.write_all(chunk).await?),
35 Self::Framed(writer) => writer.push(chunk).await,
36 }
37 }
38
39 async fn finish(self) -> Result<(), Error> {
40 match self {
41 Self::Raw(mut file) => {
42 file.flush().await?;
43 Ok(file.sync_all().await?)
44 }
45 Self::Framed(writer) => writer.finish().await,
46 }
47 }
48}
49
50pub enum Object {
52 Raw { file: fs::File, size: u64 },
53 Framed(codec::Framed),
54}
55
56impl Object {
57 pub fn size(&self) -> u64 {
58 match self {
59 Self::Raw { size, .. } => *size,
60 Self::Framed(framed) => framed.plaintext(),
61 }
62 }
63
64 pub async fn stream(
65 self,
66 start: u64,
67 length: u64,
68 ) -> Result<futures_util::stream::BoxStream<'static, Result<axum::body::Bytes, Error>>, Error>
69 {
70 use futures_util::StreamExt;
71 use tokio::io::AsyncSeekExt;
72
73 match self {
74 Self::Raw { mut file, .. } => {
75 file.seek(std::io::SeekFrom::Start(start)).await?;
76 let reader =
77 tokio_util::io::ReaderStream::new(tokio::io::AsyncReadExt::take(file, length));
78
79 Ok(reader.map(|chunk| chunk.map_err(Error::from)).boxed())
80 }
81 Self::Framed(framed) => Ok(framed.stream(start, length).boxed()),
82 }
83 }
84}
85pub use staging::{Reclaimed, reclaim};
86pub use sweep::SweepReport;
87
88#[derive(Debug, Clone, Copy)]
93pub struct Budget {
94 pub used: u64,
95 pub limit: u64,
96}
97
98impl Budget {
99 pub fn exceeded_by(&self, arriving: u64) -> bool {
100 self.used + arriving > self.limit
101 }
102
103 pub fn refusal(&self) -> Error {
104 Error::OverQuota {
105 used: self.used,
106 limit: self.limit,
107 }
108 }
109}
110
111pub struct LocalStore {
112 root: PathBuf,
113 counter: AtomicU64,
114 usage: Mutex<Option<(Instant, u64, u64)>>,
115 per_namespace: Mutex<HashMap<String, (Instant, u64, u64)>>,
116 scans: AtomicU64,
117 max_object_size: Option<u64>,
118 compression: Option<i32>,
119}
120
121impl LocalStore {
122 pub fn new(root: impl Into<PathBuf>) -> Self {
123 Self {
124 root: root.into(),
125 counter: AtomicU64::new(0),
126 usage: Mutex::new(None),
127 per_namespace: Mutex::new(HashMap::new()),
128 scans: AtomicU64::new(0),
129 max_object_size: None,
130 compression: None,
131 }
132 }
133
134 pub fn with_compression(mut self, level: Option<i32>) -> Self {
135 self.compression = level;
136 self
137 }
138
139 pub fn with_max_object_size(mut self, limit: Option<u64>) -> Self {
140 self.max_object_size = limit;
141 self
142 }
143
144 pub fn validate_oid(oid: &str) -> Result<(), Error> {
145 let well_formed = oid.len() == 64
146 && oid
147 .bytes()
148 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
149
150 well_formed.then_some(()).ok_or(Error::MalformedOid)
151 }
152
153 fn object_path(&self, ns: &Namespace, oid: &str) -> PathBuf {
154 self.root
155 .join(ns.org())
156 .join(ns.repo())
157 .join(&oid[0..2])
158 .join(&oid[2..4])
159 .join(oid)
160 }
161
162 fn content_path(&self, oid: &str) -> PathBuf {
163 self.root
164 .join(".content")
165 .join(&oid[0..2])
166 .join(&oid[2..4])
167 .join(oid)
168 }
169
170 pub fn scans(&self) -> u64 {
171 self.scans.load(Ordering::Relaxed)
172 }
173
174 pub async fn writable(&self) -> Result<(), Error> {
175 fs::create_dir_all(&self.root).await?;
176
177 let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
178 let probe = self.root.join(format!(".readiness.{ticket}"));
179
180 fs::write(&probe, b"").await?;
181 fs::remove_file(&probe).await?;
182
183 Ok(())
184 }
185
186 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
187 Self::validate_oid(oid).is_ok() && fs::metadata(self.object_path(ns, oid)).await.is_ok()
188 }
189
190 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
191 Self::validate_oid(oid)?;
192 let path = self.object_path(ns, oid);
193 let file = fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
194 let on_disk = file.metadata().await?.len();
195
196 match codec::Framed::open(file, on_disk).await? {
197 Some(framed) => Ok(Object::Framed(framed)),
198 None => Ok(Object::Raw {
199 file: fs::File::open(&path).await.map_err(|_| Error::NotFound)?,
200 size: on_disk,
201 }),
202 }
203 }
204
205 pub async fn write<S, E>(
206 &self,
207 ns: &Namespace,
208 oid: &str,
209 expected_size: Option<u64>,
210 budget: Option<Budget>,
211 mut chunks: S,
212 ) -> Result<u64, Error>
213 where
214 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
215 E: std::error::Error + Send + Sync + 'static,
216 {
217 Self::validate_oid(oid)?;
218
219 if let Some(limit) = self.max_object_size
220 && expected_size.is_some_and(|declared| declared > limit)
221 {
222 return Err(Error::TooLarge { limit });
223 }
224
225 let path = self.object_path(ns, oid);
226 let parent = path.parent().expect("object paths always have a parent");
227 fs::create_dir_all(parent).await?;
228
229 let fresh = fs::metadata(&path).await.is_err();
232
233 let staged = self.staging_path(parent, oid);
234 let outcome = self.stream_to(&staged, budget, &mut chunks).await;
235
236 match outcome {
237 Ok((digest, written)) => {
238 self.finish(&staged, &path, oid, expected_size, &digest, written)
239 .await?;
240
241 if fresh {
242 self.stored(ns, written).await;
243 }
244
245 Ok(written)
246 }
247 Err(error) => {
248 let _ = fs::remove_file(&staged).await;
249 Err(error)
250 }
251 }
252 }
253
254 fn staging_path(&self, parent: &Path, oid: &str) -> PathBuf {
255 let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
256 parent.join(format!("{oid}.{ticket}.part"))
257 }
258
259 async fn stream_to<S, E>(
260 &self,
261 staged: &Path,
262 budget: Option<Budget>,
263 chunks: &mut S,
264 ) -> Result<(String, u64), Error>
265 where
266 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
267 E: std::error::Error + Send + Sync + 'static,
268 {
269 let file = fs::File::create(staged).await?;
270 let mut sink = match self.compression {
274 Some(level) => Sink::Framed(Box::new(codec::Writer::open(file, level).await?)),
275 None => Sink::Raw(file),
276 };
277 let mut hasher = Sha256::new();
278 let mut written = 0u64;
279
280 while let Some(chunk) = chunks.next().await {
281 let chunk = chunk.map_err(std::io::Error::other)?;
282 hasher.update(&chunk);
283 written += chunk.len() as u64;
284
285 if let Some(limit) = self.max_object_size.filter(|limit| written > *limit) {
290 return Err(Error::TooLarge { limit });
291 }
292
293 if let Some(budget) = budget.filter(|budget| budget.exceeded_by(written)) {
294 return Err(budget.refusal());
295 }
296
297 sink.write(&chunk).await?;
298 }
299
300 sink.finish().await?;
301
302 Ok((hex::encode(hasher.finalize()), written))
303 }
304
305 async fn finish(
306 &self,
307 staged: &Path,
308 final_path: &Path,
309 oid: &str,
310 expected_size: Option<u64>,
311 digest: &str,
312 written: u64,
313 ) -> Result<(), Error> {
314 if let Some(declared) = expected_size.filter(|declared| *declared != written) {
315 let _ = fs::remove_file(staged).await;
316 return Err(Error::SizeMismatch {
317 declared,
318 actual: written,
319 });
320 }
321
322 if digest != oid {
323 let _ = fs::remove_file(staged).await;
324 return Err(Error::OidMismatch {
325 declared: oid.to_owned(),
326 actual: digest.to_owned(),
327 });
328 }
329
330 self.link_or_move(staged, final_path, oid).await
331 }
332
333 async fn link_or_move(&self, staged: &Path, final_path: &Path, oid: &str) -> Result<(), Error> {
340 let content = self.content_path(oid);
341 let parent = content.parent().expect("content paths have a parent");
342 fs::create_dir_all(parent).await?;
343
344 if fs::metadata(&content).await.is_err() {
345 fs::rename(staged, &content).await?;
346 }
347
348 match self.link(&content, final_path).await {
349 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
355 fs::rename(staged, &content).await?;
356 self.link(&content, final_path).await?;
357 }
358 outcome => outcome?,
359 }
360
361 let _ = fs::remove_file(staged).await;
362 Ok(())
363 }
364
365 async fn link(&self, content: &Path, final_path: &Path) -> Result<(), std::io::Error> {
366 let from = content.to_path_buf();
367 let to = final_path.to_path_buf();
368 let linked = tokio::task::spawn_blocking(move || std::fs::hard_link(&from, &to))
369 .await
370 .map_err(std::io::Error::other)?;
371
372 match linked {
373 Ok(()) => Ok(()),
374 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
375 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(error),
376 Err(_) => fs::copy(content, final_path).await.map(|_| ()),
380 }
381 }
382}