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.width,
112 meta.height,
113 meta.label,
114 );
115 let path = self.path_for_id(&meta.id);
116 put_at_path(path, bytes, meta)
117 }
118
119 async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
120 get_at_path(self.path_for_id(id), id)
121 }
122
123 async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
124 delete_at_path(self.path_for_id(id))
125 }
126
127 async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
128 head_at_path(self.path_for_id(id), id)
129 }
130
131 async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
132 let content_root = self.content_root();
133 let mut blobs = Vec::new();
134 let prefix_dirs = match fs::read_dir(&content_root) {
135 Ok(entries) => entries,
136 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(blobs),
138 Err(source) => {
139 return Err(AttachmentStoreError::Io {
140 path: content_root,
141 source,
142 });
143 }
144 };
145 for prefix_dir in prefix_dirs {
146 let prefix_dir = prefix_dir.map_err(|source| AttachmentStoreError::Io {
147 path: content_root.clone(),
148 source,
149 })?;
150 if !prefix_dir
151 .file_type()
152 .map(|ty| ty.is_dir())
153 .unwrap_or(false)
154 {
155 continue;
156 }
157 let dir_path = prefix_dir.path();
158 for entry in fs::read_dir(&dir_path).map_err(|source| AttachmentStoreError::Io {
159 path: dir_path.clone(),
160 source,
161 })? {
162 let entry = entry.map_err(|source| AttachmentStoreError::Io {
163 path: dir_path.clone(),
164 source,
165 })?;
166 let file_name = entry.file_name();
167 let Some(name) = file_name.to_str() else {
168 continue;
169 };
170 if name.contains(".staging.") {
172 continue;
173 }
174 let last_modified_epoch_ms = entry
175 .metadata()
176 .ok()
177 .and_then(|meta| meta.modified().ok())
178 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
179 .map(|dur| dur.as_millis() as u64);
180 blobs.push(StoredBlobRef {
181 id: AttachmentId::new(name.to_string()),
182 last_modified_epoch_ms,
183 });
184 }
185 }
186 Ok(blobs)
187 }
188}
189
190fn put_at_path(
191 path: PathBuf,
192 bytes: Vec<u8>,
193 meta: AttachmentMeta,
194) -> Result<AttachmentRef, AttachmentStoreError> {
195 if let Some(parent) = path.parent() {
196 fs::create_dir_all(parent).map_err(|source| AttachmentStoreError::Io {
197 path: parent.to_path_buf(),
198 source,
199 })?;
200 }
201 if path.exists() {
202 refresh_mtime(&path, &bytes)?;
206 } else {
207 write_atomic(&path, &bytes)?;
208 }
209 Ok(meta.as_ref())
210}
211
212fn refresh_mtime(path: &Path, bytes: &[u8]) -> Result<(), AttachmentStoreError> {
216 match fs::OpenOptions::new().write(true).open(path) {
217 Ok(file) => {
218 if file.set_modified(std::time::SystemTime::now()).is_ok() {
219 return Ok(());
220 }
221 }
223 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
224 }
226 Err(source) => {
227 return Err(AttachmentStoreError::Io {
228 path: path.to_path_buf(),
229 source,
230 });
231 }
232 }
233 write_atomic(path, bytes)
234}
235
236fn head_at_path(
237 path: PathBuf,
238 id: &AttachmentId,
239) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
240 match fs::metadata(&path) {
241 Ok(metadata) => {
242 let last_modified_epoch_ms = metadata
243 .modified()
244 .ok()
245 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
246 .map(|dur| dur.as_millis() as u64);
247 Ok(Some(StoredBlobRef {
248 id: id.clone(),
249 last_modified_epoch_ms,
250 }))
251 }
252 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
253 Err(source) => Err(AttachmentStoreError::Io { path, source }),
254 }
255}
256
257fn get_at_path(path: PathBuf, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
258 let bytes = fs::read(&path).map_err(|source| {
259 if source.kind() == std::io::ErrorKind::NotFound {
260 AttachmentStoreError::NotFound(id.clone())
261 } else {
262 AttachmentStoreError::Io {
263 path: path.clone(),
264 source,
265 }
266 }
267 })?;
268 Ok(StoredAttachment { bytes })
269}
270
271fn delete_at_path(path: PathBuf) -> Result<(), AttachmentStoreError> {
272 match fs::remove_file(&path) {
273 Ok(()) => Ok(()),
274 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
275 Err(source) => Err(AttachmentStoreError::Io { path, source }),
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::{ImageMediaType, MediaType};
283 use std::collections::BTreeSet;
284 use std::sync::Mutex;
285
286 fn meta() -> AttachmentCreateMeta {
287 AttachmentCreateMeta::new(
288 MediaType::Image(ImageMediaType::Png),
289 Some(1),
290 Some(1),
291 Some("pixel".to_string()),
292 )
293 }
294
295 #[tokio::test]
296 async fn file_store_round_trips_bytes_and_metadata() {
297 let temp = tempfile::tempdir().expect("tempdir");
298 let store = FileAttachmentStore::new(temp.path());
299 let reference = store.put(vec![1, 2, 3], meta()).await.expect("put");
300 let stored = store.get(&reference.id).await.expect("get");
301
302 assert_eq!(stored.bytes, vec![1, 2, 3]);
303 assert_eq!(reference.byte_len, 3);
304 }
305
306 #[tokio::test]
311 async fn file_store_writes_atomically_without_temp_litter() {
312 let temp = tempfile::tempdir().expect("tempdir");
313 let store = FileAttachmentStore::new(temp.path());
314 let reference = store.put(vec![9, 8, 7, 6], meta()).await.expect("put");
315
316 let final_path = store.path_for_id(&reference.id);
317 assert!(final_path.exists(), "content file must be in place");
318
319 let mut staging_files = Vec::new();
320 let dir = final_path.parent().expect("content dir");
321 for entry in fs::read_dir(dir).expect("read content dir") {
322 let path = entry.expect("dir entry").path();
323 if path
324 .file_name()
325 .and_then(|name| name.to_str())
326 .map(|name| name.contains(".staging."))
327 .unwrap_or(false)
328 {
329 staging_files.push(path);
330 }
331 }
332 assert!(
333 staging_files.is_empty(),
334 "atomic write must not leave staging files behind: {staging_files:?}"
335 );
336
337 let stored = store.get(&reference.id).await.expect("get");
339 assert_eq!(stored.bytes, vec![9, 8, 7, 6]);
340 }
341
342 #[tokio::test]
346 async fn file_store_ignores_stale_staging_file() {
347 let temp = tempfile::tempdir().expect("tempdir");
348 let store = FileAttachmentStore::new(temp.path());
349 let content_id = content_id(&[1, 1, 1]);
350 let id = AttachmentId::new(content_id.to_string());
351 let final_path = store.path_for_id(&id);
352 let parent = final_path.parent().expect("parent");
353 fs::create_dir_all(parent).expect("mkdir");
354 let mut stale = final_path.file_name().expect("name").to_os_string();
356 stale.push(".staging.999.0.tmp");
357 fs::write(parent.join(&stale), b"stale partial write").expect("seed stale staging");
358
359 let reference = store
360 .put(vec![1, 1, 1], meta())
361 .await
362 .expect("put over stale staging");
363 let stored = store.get(&reference.id).await.expect("get");
364 assert_eq!(stored.bytes, vec![1, 1, 1]);
365
366 let listed: Vec<AttachmentId> = store
368 .list()
369 .await
370 .expect("list")
371 .into_iter()
372 .map(|blob| blob.id)
373 .collect();
374 assert_eq!(listed, vec![reference.id]);
375 }
376
377 #[tokio::test]
381 async fn file_store_put_refreshes_mtime_on_dedup_hit() {
382 let temp = tempfile::tempdir().expect("tempdir");
383 let store = FileAttachmentStore::new(temp.path());
384 let reference = store.put(vec![2, 4, 6], meta()).await.expect("put");
385 let path = store.path_for_id(&reference.id);
386
387 let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
389 fs::OpenOptions::new()
390 .write(true)
391 .open(&path)
392 .expect("open blob")
393 .set_modified(old)
394 .expect("set old mtime");
395 let aged = store
396 .head(&reference.id)
397 .await
398 .expect("head")
399 .expect("blob present");
400
401 store.put(vec![2, 4, 6], meta()).await.expect("dedup put");
403 let refreshed = store
404 .head(&reference.id)
405 .await
406 .expect("head")
407 .expect("blob present");
408
409 assert!(
410 refreshed.last_modified_epoch_ms > aged.last_modified_epoch_ms,
411 "dedup-hit put must refresh mtime: {:?} !> {:?}",
412 refreshed.last_modified_epoch_ms,
413 aged.last_modified_epoch_ms
414 );
415 }
416
417 #[tokio::test]
418 async fn file_store_is_flat_content_addressed_across_writers() {
419 let temp = tempfile::tempdir().expect("tempdir");
420 let store = FileAttachmentStore::new(temp.path());
421 let first = store.put(vec![7, 7, 7], meta()).await.expect("put first");
424 let second = store.put(vec![7, 7, 7], meta()).await.expect("put second");
425 assert_eq!(first.id, second.id);
426 assert_eq!(store.path_for_id(&first.id), store.path_for_id(&second.id));
427
428 let listed: BTreeSet<AttachmentId> = store
429 .list()
430 .await
431 .expect("list")
432 .into_iter()
433 .map(|blob| blob.id)
434 .collect();
435 assert_eq!(listed.len(), 1);
436 assert!(listed.contains(&first.id));
437 }
438
439 #[tokio::test]
443 async fn file_attachment_store_satisfies_conformance() {
444 use std::sync::Arc;
445
446 use crate::testing::conformance::ReopenableAttachmentStore;
447
448 let dirs: Arc<Mutex<Vec<tempfile::TempDir>>> = Arc::new(Mutex::new(Vec::new()));
451 crate::testing::conformance::attachment_store_reopenable(
452 || {
453 let dir = tempfile::tempdir().expect("tempdir");
454 let open =
455 Arc::new(FileAttachmentStore::new(dir.path())) as Arc<dyn AttachmentStore>;
456 let reopen =
457 Arc::new(FileAttachmentStore::new(dir.path())) as Arc<dyn AttachmentStore>;
458 dirs.lock().expect("dirs lock").push(dir);
459 ReopenableAttachmentStore { open, reopen }
460 },
461 AttachmentStorePersistence::Durable,
462 )
463 .await;
464 }
465}