1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentMeta, AttachmentRef};
6
7use super::{
8 AttachmentStore, AttachmentStoreError, AttachmentStorePersistence, StoredAttachment,
9 StoredBlobRef, content_id,
10};
11
12static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0);
15
16pub struct FileAttachmentStore {
17 root: PathBuf,
18}
19
20impl FileAttachmentStore {
21 pub fn new(root: impl Into<PathBuf>) -> Self {
22 Self { root: root.into() }
23 }
24
25 pub fn root(&self) -> &Path {
26 &self.root
27 }
28
29 fn content_root(&self) -> PathBuf {
30 self.root.join("sha256")
31 }
32
33 fn path_for_id(&self, id: &AttachmentId) -> PathBuf {
34 let id = id.as_str();
35 let prefix = id.get(..2).unwrap_or(id);
36 self.content_root().join(prefix).join(id)
37 }
38}
39
40fn write_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), AttachmentStoreError> {
49 let counter = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed);
50 let mut staging_name = final_path
51 .file_name()
52 .map(|name| name.to_os_string())
53 .unwrap_or_default();
54 staging_name.push(format!(".staging.{}.{counter}.tmp", std::process::id()));
55 let tmp_path = final_path
56 .parent()
57 .map(|parent| parent.join(&staging_name))
58 .unwrap_or_else(|| PathBuf::from(&staging_name));
59
60 let io_err = |path: &Path, source: std::io::Error| AttachmentStoreError::Io {
61 path: path.to_path_buf(),
62 source,
63 };
64
65 let write_result = (|| {
66 let mut file = fs::File::create(&tmp_path).map_err(|source| io_err(&tmp_path, source))?;
67 std::io::Write::write_all(&mut file, bytes).map_err(|source| io_err(&tmp_path, source))?;
68 file.sync_all()
70 .map_err(|source| io_err(&tmp_path, source))?;
71 fs::rename(&tmp_path, final_path).map_err(|source| io_err(final_path, source))?;
72 if let Some(parent) = final_path.parent() {
75 fsync_dir(parent);
76 }
77 Ok(())
78 })();
79
80 if write_result.is_err() {
81 let _ = fs::remove_file(&tmp_path);
83 }
84 write_result
85}
86
87fn fsync_dir(dir: &Path) {
91 if let Ok(handle) = fs::File::open(dir) {
92 let _ = handle.sync_all();
93 }
94}
95
96#[async_trait::async_trait]
97impl AttachmentStore for FileAttachmentStore {
98 fn persistence(&self) -> AttachmentStorePersistence {
99 AttachmentStorePersistence::Durable
100 }
101
102 async fn put(
103 &self,
104 bytes: Vec<u8>,
105 meta: AttachmentCreateMeta,
106 ) -> Result<AttachmentRef, AttachmentStoreError> {
107 let meta = AttachmentMeta::new(
108 content_id(&bytes),
109 meta.media_type,
110 bytes.len() as u64,
111 meta.type_metadata,
112 meta.label,
113 );
114 let path = self.path_for_id(&meta.id);
115 put_at_path(path, bytes, meta)
116 }
117
118 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
119 get_at_path(self.path_for_id(id), id)
120 }
121
122 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
123 delete_at_path(self.path_for_id(id))
124 }
125
126 async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
127 head_at_path(self.path_for_id(id), id)
128 }
129
130 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
131 let content_root = self.content_root();
132 let mut blobs = Vec::new();
133 let prefix_dirs = match fs::read_dir(&content_root) {
134 Ok(entries) => entries,
135 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(blobs),
137 Err(source) => {
138 return Err(AttachmentStoreError::Io {
139 path: content_root,
140 source,
141 });
142 }
143 };
144 for prefix_dir in prefix_dirs {
145 let prefix_dir = prefix_dir.map_err(|source| AttachmentStoreError::Io {
146 path: content_root.clone(),
147 source,
148 })?;
149 if !prefix_dir
150 .file_type()
151 .map(|ty| ty.is_dir())
152 .unwrap_or(false)
153 {
154 continue;
155 }
156 let dir_path = prefix_dir.path();
157 for entry in fs::read_dir(&dir_path).map_err(|source| AttachmentStoreError::Io {
158 path: dir_path.clone(),
159 source,
160 })? {
161 let entry = entry.map_err(|source| AttachmentStoreError::Io {
162 path: dir_path.clone(),
163 source,
164 })?;
165 let file_name = entry.file_name();
166 let Some(name) = file_name.to_str() else {
167 continue;
168 };
169 if name.contains(".staging.") {
171 continue;
172 }
173 let last_modified_epoch_ms = entry
174 .metadata()
175 .ok()
176 .and_then(|meta| meta.modified().ok())
177 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
178 .map(|dur| dur.as_millis() as u64);
179 blobs.push(StoredBlobRef {
180 id: AttachmentId::new(name.to_string()),
181 last_modified_epoch_ms,
182 });
183 }
184 }
185 Ok(blobs)
186 }
187}
188
189fn put_at_path(
190 path: PathBuf,
191 bytes: Vec<u8>,
192 meta: AttachmentMeta,
193) -> Result<AttachmentRef, AttachmentStoreError> {
194 if let Some(parent) = path.parent() {
195 fs::create_dir_all(parent).map_err(|source| AttachmentStoreError::Io {
196 path: parent.to_path_buf(),
197 source,
198 })?;
199 }
200 if path.exists() {
201 refresh_mtime(&path, &bytes)?;
205 } else {
206 write_atomic(&path, &bytes)?;
207 }
208 Ok(meta.as_ref())
209}
210
211fn refresh_mtime(path: &Path, bytes: &[u8]) -> Result<(), AttachmentStoreError> {
215 match fs::OpenOptions::new().write(true).open(path) {
216 Ok(file) => {
217 if file.set_modified(std::time::SystemTime::now()).is_ok() {
218 return Ok(());
219 }
220 }
222 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
223 }
225 Err(source) => {
226 return Err(AttachmentStoreError::Io {
227 path: path.to_path_buf(),
228 source,
229 });
230 }
231 }
232 write_atomic(path, bytes)
233}
234
235fn head_at_path(
236 path: PathBuf,
237 id: &AttachmentId,
238) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
239 match fs::metadata(&path) {
240 Ok(metadata) => {
241 let last_modified_epoch_ms = metadata
242 .modified()
243 .ok()
244 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
245 .map(|dur| dur.as_millis() as u64);
246 Ok(Some(StoredBlobRef {
247 id: id.clone(),
248 last_modified_epoch_ms,
249 }))
250 }
251 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
252 Err(source) => Err(AttachmentStoreError::Io { path, source }),
253 }
254}
255
256fn get_at_path(path: PathBuf, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
257 let bytes = fs::read(&path).map_err(|source| {
258 if source.kind() == std::io::ErrorKind::NotFound {
259 AttachmentStoreError::NotFound(id.clone())
260 } else {
261 AttachmentStoreError::Io {
262 path: path.clone(),
263 source,
264 }
265 }
266 })?;
267 Ok(StoredAttachment { bytes })
268}
269
270fn delete_at_path(path: PathBuf) -> Result<(), AttachmentStoreError> {
271 match fs::remove_file(&path) {
272 Ok(()) => Ok(()),
273 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
274 Err(source) => Err(AttachmentStoreError::Io { path, source }),
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::{AttachmentTypeMetadata, MediaType};
282 use std::collections::BTreeSet;
283 use std::sync::Mutex;
284
285 fn meta() -> AttachmentCreateMeta {
286 AttachmentCreateMeta::new(
287 MediaType::parse("image/png").unwrap(),
288 Some(AttachmentTypeMetadata::image(Some(1), Some(1))),
289 Some("pixel".to_string()),
290 )
291 }
292
293 #[tokio::test]
294 async fn file_store_round_trips_bytes_and_metadata() {
295 let temp = tempfile::tempdir().expect("tempdir");
296 let store = FileAttachmentStore::new(temp.path());
297 let reference = store.put(vec![1, 2, 3], meta()).await.expect("put");
298 let stored = store.get(&reference.id).await.expect("get");
299
300 assert_eq!(stored.bytes, vec![1, 2, 3]);
301 assert_eq!(reference.byte_len, 3);
302 }
303
304 #[tokio::test]
309 async fn file_store_writes_atomically_without_temp_litter() {
310 let temp = tempfile::tempdir().expect("tempdir");
311 let store = FileAttachmentStore::new(temp.path());
312 let reference = store.put(vec![9, 8, 7, 6], meta()).await.expect("put");
313
314 let final_path = store.path_for_id(&reference.id);
315 assert!(final_path.exists(), "content file must be in place");
316
317 let mut staging_files = Vec::new();
318 let dir = final_path.parent().expect("content dir");
319 for entry in fs::read_dir(dir).expect("read content dir") {
320 let path = entry.expect("dir entry").path();
321 if path
322 .file_name()
323 .and_then(|name| name.to_str())
324 .map(|name| name.contains(".staging."))
325 .unwrap_or(false)
326 {
327 staging_files.push(path);
328 }
329 }
330 assert!(
331 staging_files.is_empty(),
332 "atomic write must not leave staging files behind: {staging_files:?}"
333 );
334
335 let stored = store.get(&reference.id).await.expect("get");
337 assert_eq!(stored.bytes, vec![9, 8, 7, 6]);
338 }
339
340 #[tokio::test]
344 async fn file_store_ignores_stale_staging_file() {
345 let temp = tempfile::tempdir().expect("tempdir");
346 let store = FileAttachmentStore::new(temp.path());
347 let content_id = content_id(&[1, 1, 1]);
348 let id = AttachmentId::new(content_id.to_string());
349 let final_path = store.path_for_id(&id);
350 let parent = final_path.parent().expect("parent");
351 fs::create_dir_all(parent).expect("mkdir");
352 let mut stale = final_path.file_name().expect("name").to_os_string();
354 stale.push(".staging.999.0.tmp");
355 fs::write(parent.join(&stale), b"stale partial write").expect("seed stale staging");
356
357 let reference = store
358 .put(vec![1, 1, 1], meta())
359 .await
360 .expect("put over stale staging");
361 let stored = store.get(&reference.id).await.expect("get");
362 assert_eq!(stored.bytes, vec![1, 1, 1]);
363
364 let listed: Vec<AttachmentId> = store
366 .list()
367 .await
368 .expect("list")
369 .into_iter()
370 .map(|blob| blob.id)
371 .collect();
372 assert_eq!(listed, vec![reference.id]);
373 }
374
375 #[tokio::test]
379 async fn file_store_put_refreshes_mtime_on_dedup_hit() {
380 let temp = tempfile::tempdir().expect("tempdir");
381 let store = FileAttachmentStore::new(temp.path());
382 let reference = store.put(vec![2, 4, 6], meta()).await.expect("put");
383 let path = store.path_for_id(&reference.id);
384
385 let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
387 fs::OpenOptions::new()
388 .write(true)
389 .open(&path)
390 .expect("open blob")
391 .set_modified(old)
392 .expect("set old mtime");
393 let aged = store
394 .head(&reference.id)
395 .await
396 .expect("head")
397 .expect("blob present");
398
399 store.put(vec![2, 4, 6], meta()).await.expect("dedup put");
401 let refreshed = store
402 .head(&reference.id)
403 .await
404 .expect("head")
405 .expect("blob present");
406
407 assert!(
408 refreshed.last_modified_epoch_ms > aged.last_modified_epoch_ms,
409 "dedup-hit put must refresh mtime: {:?} !> {:?}",
410 refreshed.last_modified_epoch_ms,
411 aged.last_modified_epoch_ms
412 );
413 }
414
415 #[tokio::test]
416 async fn file_store_is_flat_content_addressed_across_writers() {
417 let temp = tempfile::tempdir().expect("tempdir");
418 let store = FileAttachmentStore::new(temp.path());
419 let first = store.put(vec![7, 7, 7], meta()).await.expect("put first");
422 let second = store.put(vec![7, 7, 7], meta()).await.expect("put second");
423 assert_eq!(first.id, second.id);
424 assert_eq!(store.path_for_id(&first.id), store.path_for_id(&second.id));
425
426 let listed: BTreeSet<AttachmentId> = store
427 .list()
428 .await
429 .expect("list")
430 .into_iter()
431 .map(|blob| blob.id)
432 .collect();
433 assert_eq!(listed.len(), 1);
434 assert!(listed.contains(&first.id));
435 }
436
437 #[tokio::test]
441 async fn file_attachment_store_satisfies_conformance() {
442 use std::sync::Arc;
443
444 use crate::testing::conformance::ReopenableAttachmentStore;
445
446 let dirs: Arc<Mutex<Vec<tempfile::TempDir>>> = Arc::new(Mutex::new(Vec::new()));
449 crate::testing::conformance::attachment_store_reopenable(
450 || {
451 let dir = tempfile::tempdir().expect("tempdir");
452 let open =
453 Arc::new(FileAttachmentStore::new(dir.path())) as Arc<dyn AttachmentStore>;
454 let reopen =
455 Arc::new(FileAttachmentStore::new(dir.path())) as Arc<dyn AttachmentStore>;
456 dirs.lock().expect("dirs lock").push(dir);
457 ReopenableAttachmentStore { open, reopen }
458 },
459 AttachmentStorePersistence::Durable,
460 )
461 .await;
462 }
463}