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