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.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            // No content written yet: an empty store lists nothing.
137            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                // Skip any in-flight staging files.
171                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        // Dedup hit: refresh the blob's modification time so a GC sweep that
203        // snapshotted the roots before this put (a fresh intent for the same
204        // content id) cannot reclaim the now-freshly-referenced blob.
205        refresh_mtime(&path, &bytes)?;
206    } else {
207        write_atomic(&path, &bytes)?;
208    }
209    Ok(meta.as_ref())
210}
211
212/// Refresh a blob's modification time on a dedup-hit `put`. Prefers a cheap
213/// `set_modified`; if the platform refuses it (or the file raced with a delete),
214/// falls back to a full crash-atomic rewrite, which necessarily bumps the mtime.
215fn 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            // set_modified refused (platform quirk): rewrite below.
222        }
223        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
224            // Raced with a concurrent delete: rewrite below.
225        }
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    // `put` must write crash-atomically (stage into a unique sibling, then
307    // rename). After a successful put there must be no leftover staging files
308    // in the content directory — proof the temp file was renamed into place
309    // rather than written in situ.
310    #[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        // The bytes round-trip in full (no truncation from a partial write).
338        let stored = store.get(&reference.id).await.expect("get");
339        assert_eq!(stored.bytes, vec![9, 8, 7, 6]);
340    }
341
342    // A stale staging file left by a crashed prior write must not block a
343    // subsequent successful put — unique staging names sidestep it entirely,
344    // and it is not mistaken for content.
345    #[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        // Seed a stale staging file with a plausible prior-crash name.
355        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        // The stale staging file is not enumerated as a blob.
367        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    // Fix C: a dedup-hit `put` (identical bytes already stored) must refresh the
378    // blob's modification time, so a GC sweep that snapshotted the roots before
379    // this put cannot reclaim the now-freshly-referenced blob.
380    #[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        // Age the blob far into the past to make the refresh observable.
388        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        // Identical bytes: a dedup hit that must still refresh the mtime.
402        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        // Identical bytes written twice resolve to ONE physical blob (flat,
422        // content-addressed — no per-session namespace).
423        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    // Runs the backend-agnostic `AttachmentStore` conformance suite against
440    // the file-backed implementation. The same suite runs against the
441    // in-memory store, so both backends are held to one contract.
442    #[tokio::test]
443    async fn file_attachment_store_satisfies_conformance() {
444        use std::sync::Arc;
445
446        use crate::testing::conformance::ReopenableAttachmentStore;
447
448        // Each `make()` call needs its own root that outlives the returned
449        // store. Keep the tempdirs alive for the duration of the suite.
450        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}