1mod staging;
2mod sweep;
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Instant;
8
9use tokio::sync::Mutex;
10
11use futures_util::{Stream, StreamExt};
12use sha2::{Digest, Sha256};
13use tokio::fs;
14use tokio::io::AsyncWriteExt;
15
16use crate::error::Error;
17use crate::namespace::Namespace;
18
19pub use staging::{Reclaimed, reclaim};
20pub use sweep::SweepReport;
21
22pub struct LocalStore {
23 root: PathBuf,
24 counter: AtomicU64,
25 usage: Mutex<Option<(Instant, u64, u64)>>,
26 per_namespace: Mutex<HashMap<String, (Instant, u64, u64)>>,
27 scans: AtomicU64,
28}
29
30impl LocalStore {
31 pub fn new(root: impl Into<PathBuf>) -> Self {
32 Self {
33 root: root.into(),
34 counter: AtomicU64::new(0),
35 usage: Mutex::new(None),
36 per_namespace: Mutex::new(HashMap::new()),
37 scans: AtomicU64::new(0),
38 }
39 }
40
41 pub fn validate_oid(oid: &str) -> Result<(), Error> {
42 let well_formed = oid.len() == 64
43 && oid
44 .bytes()
45 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
46
47 well_formed.then_some(()).ok_or(Error::MalformedOid)
48 }
49
50 fn object_path(&self, ns: &Namespace, oid: &str) -> PathBuf {
51 self.root
52 .join(ns.org())
53 .join(ns.repo())
54 .join(&oid[0..2])
55 .join(&oid[2..4])
56 .join(oid)
57 }
58
59 fn content_path(&self, oid: &str) -> PathBuf {
60 self.root
61 .join(".content")
62 .join(&oid[0..2])
63 .join(&oid[2..4])
64 .join(oid)
65 }
66
67 pub fn scans(&self) -> u64 {
68 self.scans.load(Ordering::Relaxed)
69 }
70
71 pub async fn writable(&self) -> Result<(), Error> {
72 fs::create_dir_all(&self.root).await?;
73
74 let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
75 let probe = self.root.join(format!(".readiness.{ticket}"));
76
77 fs::write(&probe, b"").await?;
78 fs::remove_file(&probe).await?;
79
80 Ok(())
81 }
82
83 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
84 Self::validate_oid(oid).is_ok() && fs::metadata(self.object_path(ns, oid)).await.is_ok()
85 }
86
87 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<(fs::File, u64), Error> {
88 Self::validate_oid(oid)?;
89 let path = self.object_path(ns, oid);
90 let file = fs::File::open(&path).await.map_err(|_| Error::NotFound)?;
91 let size = file.metadata().await?.len();
92 Ok((file, size))
93 }
94
95 pub async fn write<S, E>(
96 &self,
97 ns: &Namespace,
98 oid: &str,
99 expected_size: Option<u64>,
100 mut chunks: S,
101 ) -> Result<u64, Error>
102 where
103 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
104 E: std::error::Error + Send + Sync + 'static,
105 {
106 Self::validate_oid(oid)?;
107
108 let path = self.object_path(ns, oid);
109 let parent = path.parent().expect("object paths always have a parent");
110 fs::create_dir_all(parent).await?;
111
112 let staged = self.staging_path(parent, oid);
113 let outcome = self.stream_to(&staged, &mut chunks).await;
114
115 match outcome {
116 Ok((digest, written)) => {
117 self.finish(&staged, &path, oid, expected_size, &digest, written)
118 .await?;
119 Ok(written)
120 }
121 Err(error) => {
122 let _ = fs::remove_file(&staged).await;
123 Err(error)
124 }
125 }
126 }
127
128 fn staging_path(&self, parent: &Path, oid: &str) -> PathBuf {
129 let ticket = self.counter.fetch_add(1, Ordering::Relaxed);
130 parent.join(format!("{oid}.{ticket}.part"))
131 }
132
133 async fn stream_to<S, E>(&self, staged: &Path, chunks: &mut S) -> Result<(String, u64), Error>
134 where
135 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
136 E: std::error::Error + Send + Sync + 'static,
137 {
138 let mut file = fs::File::create(staged).await?;
139 let mut hasher = Sha256::new();
140 let mut written = 0u64;
141
142 while let Some(chunk) = chunks.next().await {
143 let chunk = chunk.map_err(std::io::Error::other)?;
144 hasher.update(&chunk);
145 written += chunk.len() as u64;
146 file.write_all(&chunk).await?;
147 }
148
149 file.flush().await?;
150 file.sync_all().await?;
151
152 Ok((hex::encode(hasher.finalize()), written))
153 }
154
155 async fn finish(
156 &self,
157 staged: &Path,
158 final_path: &Path,
159 oid: &str,
160 expected_size: Option<u64>,
161 digest: &str,
162 written: u64,
163 ) -> Result<(), Error> {
164 if let Some(declared) = expected_size.filter(|declared| *declared != written) {
165 let _ = fs::remove_file(staged).await;
166 return Err(Error::SizeMismatch {
167 declared,
168 actual: written,
169 });
170 }
171
172 if digest != oid {
173 let _ = fs::remove_file(staged).await;
174 return Err(Error::OidMismatch {
175 declared: oid.to_owned(),
176 actual: digest.to_owned(),
177 });
178 }
179
180 self.link_or_move(staged, final_path, oid).await
181 }
182
183 async fn link_or_move(&self, staged: &Path, final_path: &Path, oid: &str) -> Result<(), Error> {
190 let content = self.content_path(oid);
191 let parent = content.parent().expect("content paths have a parent");
192 fs::create_dir_all(parent).await?;
193
194 if fs::metadata(&content).await.is_err() {
195 fs::rename(staged, &content).await?;
196 }
197
198 match self.link(&content, final_path).await {
199 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
205 fs::rename(staged, &content).await?;
206 self.link(&content, final_path).await?;
207 }
208 outcome => outcome?,
209 }
210
211 let _ = fs::remove_file(staged).await;
212 Ok(())
213 }
214
215 async fn link(&self, content: &Path, final_path: &Path) -> Result<(), std::io::Error> {
216 let from = content.to_path_buf();
217 let to = final_path.to_path_buf();
218 let linked = tokio::task::spawn_blocking(move || std::fs::hard_link(&from, &to))
219 .await
220 .map_err(std::io::Error::other)?;
221
222 match linked {
223 Ok(()) => Ok(()),
224 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
225 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(error),
226 Err(_) => fs::copy(content, final_path).await.map(|_| ()),
230 }
231 }
232}