1mod backend;
2mod codec;
3pub mod crypt;
4mod dedupe;
5mod rewrite;
6pub mod s3;
7mod staging;
8mod sweep;
9mod usage;
10mod verify;
11mod walk;
12
13#[cfg(test)]
14mod tests;
15
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::time::Instant;
19
20use tokio::sync::Mutex;
21
22use futures_util::{Stream, StreamExt};
23use sha2::{Digest, Sha256};
24use tokio::fs;
25use tokio::io::AsyncWriteExt;
26
27use crate::error::Error;
28use crate::namespace::Namespace;
29
30pub use backend::Store;
31pub use dedupe::DedupeReport;
32use dedupe::shares_bytes_with;
33pub use rewrite::CompressReport;
34pub use verify::VerifyReport;
35
36enum Sink {
37 Raw(fs::File),
38 Framed(Box<codec::Writer>),
39}
40
41impl Sink {
42 async fn write(&mut self, chunk: &[u8]) -> Result<(), Error> {
43 match self {
44 Self::Raw(file) => Ok(file.write_all(chunk).await?),
45 Self::Framed(writer) => writer.push(chunk).await,
46 }
47 }
48
49 async fn finish(self) -> Result<(), Error> {
50 match self {
51 Self::Raw(mut file) => {
52 file.flush().await?;
53 Ok(file.sync_all().await?)
54 }
55 Self::Framed(writer) => writer.finish().await,
56 }
57 }
58}
59
60pub struct Staged {
62 pub path: PathBuf,
63 destination: PathBuf,
64 pub written: u64,
65 fresh: bool,
66}
67
68pub enum Object {
70 Raw {
71 file: fs::File,
72 size: u64,
73 },
74 Framed(codec::Framed),
75 Remote {
76 bucket: s3::S3Store,
77 oid: String,
78 size: u64,
79 },
80}
81
82impl Object {
83 pub fn size(&self) -> u64 {
84 match self {
85 Self::Raw { size, .. } => *size,
86 Self::Framed(framed) => framed.plaintext(),
87 Self::Remote { size, .. } => *size,
88 }
89 }
90
91 pub async fn stream(
92 self,
93 start: u64,
94 length: u64,
95 ) -> Result<futures_util::stream::BoxStream<'static, Result<axum::body::Bytes, Error>>, Error>
96 {
97 use futures_util::StreamExt;
98 use tokio::io::AsyncSeekExt;
99
100 match self {
101 Self::Raw { mut file, .. } => {
102 file.seek(std::io::SeekFrom::Start(start)).await?;
103 let reader =
104 tokio_util::io::ReaderStream::new(tokio::io::AsyncReadExt::take(file, length));
105
106 Ok(reader.map(|chunk| chunk.map_err(Error::from)).boxed())
107 }
108 Self::Framed(framed) => Ok(framed.stream(start, length).boxed()),
109 Self::Remote { bucket, oid, .. } => {
110 let chunks = bucket.read(&oid, start, length).await?;
111
112 Ok(chunks
113 .map(|chunk| {
114 chunk.map_err(|error| Error::Storage(std::io::Error::other(error)))
115 })
116 .boxed())
117 }
118 }
119 }
120}
121pub use staging::{Reclaimed, reclaim};
122pub use sweep::SweepReport;
123
124#[derive(Debug, Clone, Copy)]
129pub struct Budget {
130 pub used: u64,
131 pub limit: u64,
132}
133
134impl Budget {
135 pub fn exceeded_by(&self, arriving: u64) -> bool {
136 self.used + arriving > self.limit
137 }
138
139 pub fn refusal(&self) -> Error {
140 Error::OverQuota {
141 used: self.used,
142 limit: self.limit,
143 }
144 }
145}
146
147#[derive(Debug)]
151pub struct Written {
152 pub bytes: u64,
153 pub fresh: bool,
154}
155
156pub struct LocalStore {
157 root: PathBuf,
158 counter: AtomicU64,
159 usage: Mutex<Option<(Instant, u64, u64)>>,
160 scans: AtomicU64,
161 max_object_size: Option<u64>,
162 compression: Option<i32>,
163 keys: Option<std::sync::Arc<crypt::Keyring>>,
166}
167
168impl LocalStore {
169 pub fn new(root: impl Into<PathBuf>) -> Self {
170 Self {
171 root: root.into(),
172 counter: AtomicU64::new(0),
173 usage: Mutex::new(None),
174 scans: AtomicU64::new(0),
175 max_object_size: None,
176 compression: None,
177 keys: None,
178 }
179 }
180
181 pub fn with_compression(mut self, level: Option<i32>) -> Self {
182 self.compression = level;
183 self
184 }
185
186 pub fn with_max_object_size(mut self, limit: Option<u64>) -> Self {
187 self.max_object_size = limit;
188 self
189 }
190
191 pub fn with_encryption(mut self, keys: Option<std::sync::Arc<crypt::Keyring>>) -> Self {
192 self.keys = keys;
193 self
194 }
195
196 pub(super) fn keyring(&self) -> Option<&std::sync::Arc<crypt::Keyring>> {
197 self.keys.as_ref()
198 }
199
200 pub fn encrypts(&self) -> bool {
201 self.keys.is_some()
202 }
203
204 pub fn frames(&self) -> bool {
209 self.compression.is_some() || self.keys.is_some()
210 }
211
212 pub fn validate_oid(oid: &str) -> Result<(), Error> {
213 let well_formed = oid.len() == 64
214 && oid
215 .bytes()
216 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
217
218 well_formed.then_some(()).ok_or(Error::MalformedOid)
219 }
220
221 fn object_path(&self, ns: &Namespace, oid: &str) -> PathBuf {
222 self.root
223 .join(ns.org())
224 .join(ns.repo())
225 .join(&oid[0..2])
226 .join(&oid[2..4])
227 .join(oid)
228 }
229
230 fn content_path(&self, oid: &str) -> PathBuf {
231 self.root
232 .join(".content")
233 .join(&oid[0..2])
234 .join(&oid[2..4])
235 .join(oid)
236 }
237
238 pub fn scans(&self) -> u64 {
239 self.scans.load(Ordering::Relaxed)
240 }
241
242 pub async fn writable(&self) -> Result<(), Error> {
243 fs::create_dir_all(&self.root).await?;
244
245 let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
246 let probe = self.root.join(format!(".readiness.{ticket}"));
247
248 fs::write(&probe, b"").await?;
249 fs::remove_file(&probe).await?;
250
251 Ok(())
252 }
253
254 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
255 Self::validate_oid(oid).is_ok() && fs::metadata(self.object_path(ns, oid)).await.is_ok()
256 }
257
258 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
259 Self::validate_oid(oid)?;
260 let path = self.object_path(ns, oid);
261 let file = fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
262 let on_disk = file.metadata().await?.len();
263
264 match codec::Framed::open(
265 codec::Reader::File(file),
266 on_disk,
267 self.keys.as_deref(),
268 oid,
269 )
270 .await?
271 {
272 Some(framed) => Ok(Object::Framed(framed)),
273 None => Ok(Object::Raw {
274 file: fs::File::open(&path).await.map_err(|_| Error::NotFound)?,
275 size: on_disk,
276 }),
277 }
278 }
279
280 pub async fn stage<S, E>(
286 &self,
287 ns: &Namespace,
288 oid: &str,
289 expected_size: Option<u64>,
290 budget: Option<Budget>,
291 mut chunks: S,
292 ) -> Result<Staged, Error>
293 where
294 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
295 E: std::error::Error + Send + Sync + 'static,
296 {
297 Self::validate_oid(oid)?;
298
299 if let Some(limit) = self.max_object_size
300 && expected_size.is_some_and(|declared| declared > limit)
301 {
302 return Err(Error::TooLarge { limit });
303 }
304
305 let path = self.object_path(ns, oid);
306 let parent = path.parent().expect("object paths always have a parent");
307 fs::create_dir_all(parent).await?;
308
309 let fresh = fs::metadata(&path).await.is_err();
312 let staged = self.staging_path(parent, oid);
313
314 match self.stream_to(&staged, oid, budget, &mut chunks).await {
315 Ok((digest, written)) => {
316 if let Err(error) = Self::agrees(oid, expected_size, &digest, written) {
317 let _ = fs::remove_file(&staged).await;
318 return Err(error);
319 }
320
321 Ok(Staged {
322 path: staged,
323 destination: path,
324 written,
325 fresh,
326 })
327 }
328 Err(error) => {
329 let _ = fs::remove_file(&staged).await;
330 Err(error)
331 }
332 }
333 }
334
335 fn agrees(
336 oid: &str,
337 expected_size: Option<u64>,
338 digest: &str,
339 written: u64,
340 ) -> Result<(), Error> {
341 if let Some(declared) = expected_size.filter(|declared| *declared != written) {
342 return Err(Error::SizeMismatch {
343 declared,
344 actual: written,
345 });
346 }
347
348 if digest != oid {
349 return Err(Error::OidMismatch {
350 declared: oid.to_owned(),
351 actual: digest.to_owned(),
352 });
353 }
354
355 Ok(())
356 }
357
358 pub async fn write<S, E>(
359 &self,
360 ns: &Namespace,
361 oid: &str,
362 expected_size: Option<u64>,
363 budget: Option<Budget>,
364 chunks: S,
365 ) -> Result<Written, Error>
366 where
367 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
368 E: std::error::Error + Send + Sync + 'static,
369 {
370 let staged = self.stage(ns, oid, expected_size, budget, chunks).await?;
371
372 self.link_or_move(&staged.path, &staged.destination, oid)
373 .await?;
374
375 Ok(Written {
376 bytes: staged.written,
377 fresh: staged.fresh,
378 })
379 }
380
381 fn staging_path(&self, parent: &Path, oid: &str) -> PathBuf {
382 let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
383 parent.join(format!("{oid}.{ticket}.part"))
384 }
385
386 async fn stream_to<S, E>(
387 &self,
388 staged: &Path,
389 oid: &str,
390 budget: Option<Budget>,
391 chunks: &mut S,
392 ) -> Result<(String, u64), Error>
393 where
394 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
395 E: std::error::Error + Send + Sync + 'static,
396 {
397 let file = fs::File::create(staged).await?;
398 let mut sink = match (self.compression, self.keys.as_deref()) {
402 (None, None) => Sink::Raw(file),
403 (level, keys) => Sink::Framed(Box::new(
404 codec::Writer::open(file, level, keys.map(crypt::Keyring::writing), oid).await?,
405 )),
406 };
407 let mut hasher = Sha256::new();
408 let mut written = 0u64;
409
410 while let Some(chunk) = chunks.next().await {
411 let chunk = chunk.map_err(std::io::Error::other)?;
412 hasher.update(&chunk);
413 written += chunk.len() as u64;
414
415 if let Some(limit) = self.max_object_size.filter(|limit| written > *limit) {
420 return Err(Error::TooLarge { limit });
421 }
422
423 if let Some(budget) = budget.filter(|budget| budget.exceeded_by(written)) {
424 return Err(budget.refusal());
425 }
426
427 sink.write(&chunk).await?;
428 }
429
430 sink.finish().await?;
431
432 Ok((hex::encode(hasher.finalize()), written))
433 }
434
435 async fn link_or_move(&self, staged: &Path, final_path: &Path, oid: &str) -> Result<(), Error> {
442 let content = self.content_path(oid);
443 let parent = content.parent().expect("content paths have a parent");
444 fs::create_dir_all(parent).await?;
445
446 if fs::metadata(&content).await.is_err() {
447 fs::rename(staged, &content).await?;
448 }
449
450 match self.link(&content, final_path).await {
451 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
457 fs::rename(staged, &content).await?;
458 self.link(&content, final_path).await?;
459 }
460 outcome => outcome?,
461 }
462
463 let _ = fs::remove_file(staged).await;
464 Ok(())
465 }
466
467 async fn link(&self, content: &Path, final_path: &Path) -> Result<(), std::io::Error> {
468 let from = content.to_path_buf();
469 let to = final_path.to_path_buf();
470 let linked = tokio::task::spawn_blocking(move || std::fs::hard_link(&from, &to))
471 .await
472 .map_err(std::io::Error::other)?;
473
474 match linked {
475 Ok(()) => Ok(()),
476 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
477 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(error),
478 Err(_) => fs::copy(content, final_path).await.map(|_| ()),
482 }
483 }
484}