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