Skip to main content

lash_core/attachments/
file_store.rs

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
12/// Monotonic suffix so concurrent stagers within one process never collide on a
13/// staging file name.
14static 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
40/// Write `bytes` to `final_path` crash-atomically and crash-durably: stage into
41/// a per-write unique sibling file, flush it, `rename` into place, then fsync
42/// the parent directory so the new directory entry itself survives a crash. A
43/// `rename` within one directory is atomic on POSIX, so a reader (or a crash)
44/// only ever sees the old contents or the complete new contents — never a
45/// half-written file. The staging file is removed on any failure so a crashed
46/// write leaves no litter behind, and its unique name means a stale staging
47/// file from a prior crash never blocks a later write.
48fn 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        // Durability for the staged bytes before the rename.
69        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        // Make the directory entry itself crash-durable: without this, a crash
73        // after `rename` can lose the entry even though the bytes are on disk.
74        if let Some(parent) = final_path.parent() {
75            fsync_dir(parent);
76        }
77        Ok(())
78    })();
79
80    if write_result.is_err() {
81        // Never leave a partial staging file behind.
82        let _ = fs::remove_file(&tmp_path);
83    }
84    write_result
85}
86
87/// Best-effort parent-directory fsync. Not every filesystem supports fsync on a
88/// directory handle; a failure to open or sync is tolerated rather than failing
89/// an otherwise-successful write.
90fn 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            // No content written yet: an empty store lists nothing.
136            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                // Skip any in-flight staging files.
170                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        // Dedup hit: refresh the blob's modification time so a GC sweep that
202        // snapshotted the roots before this put (a fresh intent for the same
203        // content id) cannot reclaim the now-freshly-referenced blob.
204        refresh_mtime(&path, &bytes)?;
205    } else {
206        write_atomic(&path, &bytes)?;
207    }
208    Ok(meta.as_ref())
209}
210
211/// Refresh a blob's modification time on a dedup-hit `put`. Prefers a cheap
212/// `set_modified`; if the platform refuses it (or the file raced with a delete),
213/// falls back to a full crash-atomic rewrite, which necessarily bumps the mtime.
214fn 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            // set_modified refused (platform quirk): rewrite below.
221        }
222        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
223            // Raced with a concurrent delete: rewrite below.
224        }
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    // `put` must write crash-atomically (stage into a unique sibling, then
305    // rename). After a successful put there must be no leftover staging files
306    // in the content directory — proof the temp file was renamed into place
307    // rather than written in situ.
308    #[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        // The bytes round-trip in full (no truncation from a partial write).
336        let stored = store.get(&reference.id).await.expect("get");
337        assert_eq!(stored.bytes, vec![9, 8, 7, 6]);
338    }
339
340    // A stale staging file left by a crashed prior write must not block a
341    // subsequent successful put — unique staging names sidestep it entirely,
342    // and it is not mistaken for content.
343    #[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        // Seed a stale staging file with a plausible prior-crash name.
353        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        // The stale staging file is not enumerated as a blob.
365        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    // Fix C: a dedup-hit `put` (identical bytes already stored) must refresh the
376    // blob's modification time, so a GC sweep that snapshotted the roots before
377    // this put cannot reclaim the now-freshly-referenced blob.
378    #[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        // Age the blob far into the past to make the refresh observable.
386        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        // Identical bytes: a dedup hit that must still refresh the mtime.
400        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        // Identical bytes written twice resolve to ONE physical blob (flat,
420        // content-addressed — no per-session namespace).
421        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    // Runs the backend-agnostic `AttachmentStore` conformance suite against
438    // the file-backed implementation. The same suite runs against the
439    // in-memory store, so both backends are held to one contract.
440    #[tokio::test]
441    async fn file_attachment_store_satisfies_conformance() {
442        use std::sync::Arc;
443
444        use crate::testing::conformance::ReopenableAttachmentStore;
445
446        // Each `make()` call needs its own root that outlives the returned
447        // store. Keep the tempdirs alive for the duration of the suite.
448        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}