Skip to main content

lash_core/
attachments.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentMeta, AttachmentRef};
7use sha2::{Digest, Sha256};
8
9#[derive(Debug, thiserror::Error)]
10pub enum AttachmentStoreError {
11    #[error("attachment `{0}` was not found")]
12    NotFound(AttachmentId),
13    #[error("attachment store I/O failed at {path}: {source}")]
14    Io {
15        path: PathBuf,
16        #[source]
17        source: std::io::Error,
18    },
19    #[error("attachment store metadata is unavailable for `{0}`")]
20    MissingMeta(AttachmentId),
21}
22
23#[derive(Clone, Debug)]
24pub struct StoredAttachment {
25    pub meta: AttachmentMeta,
26    pub bytes: Vec<u8>,
27}
28
29pub trait AttachmentStore: Send + Sync {
30    fn put(
31        &self,
32        bytes: Vec<u8>,
33        meta: AttachmentCreateMeta,
34    ) -> Result<AttachmentRef, AttachmentStoreError>;
35
36    fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError>;
37}
38
39#[derive(Default)]
40pub struct InMemoryAttachmentStore {
41    attachments: Mutex<HashMap<AttachmentId, StoredAttachment>>,
42}
43
44impl InMemoryAttachmentStore {
45    pub fn new() -> Self {
46        Self::default()
47    }
48}
49
50impl AttachmentStore for InMemoryAttachmentStore {
51    fn put(
52        &self,
53        bytes: Vec<u8>,
54        meta: AttachmentCreateMeta,
55    ) -> Result<AttachmentRef, AttachmentStoreError> {
56        let meta = stored_meta(&bytes, meta);
57        let reference = meta.as_ref();
58        let stored = StoredAttachment { meta, bytes };
59        self.attachments
60            .lock()
61            .expect("attachment store lock")
62            .insert(reference.id.clone(), stored);
63        Ok(reference)
64    }
65
66    fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
67        self.attachments
68            .lock()
69            .expect("attachment store lock")
70            .get(id)
71            .cloned()
72            .ok_or_else(|| AttachmentStoreError::NotFound(id.clone()))
73    }
74}
75
76pub struct FileAttachmentStore {
77    root: PathBuf,
78    meta: Mutex<HashMap<AttachmentId, AttachmentMeta>>,
79}
80
81impl FileAttachmentStore {
82    pub fn new(root: impl Into<PathBuf>) -> Self {
83        Self {
84            root: root.into(),
85            meta: Mutex::new(HashMap::new()),
86        }
87    }
88
89    pub fn root(&self) -> &Path {
90        &self.root
91    }
92
93    fn path_for_id(&self, id: &AttachmentId) -> PathBuf {
94        let id = id.as_str();
95        let prefix = id.get(..2).unwrap_or(id);
96        self.root.join("sha256").join(prefix).join(id)
97    }
98
99    fn meta_path_for_id(&self, id: &AttachmentId) -> PathBuf {
100        self.path_for_id(id).with_extension("json")
101    }
102}
103
104impl AttachmentStore for FileAttachmentStore {
105    fn put(
106        &self,
107        bytes: Vec<u8>,
108        meta: AttachmentCreateMeta,
109    ) -> Result<AttachmentRef, AttachmentStoreError> {
110        let meta = stored_meta(&bytes, meta);
111        let path = self.path_for_id(&meta.id);
112        if let Some(parent) = path.parent() {
113            fs::create_dir_all(parent).map_err(|source| AttachmentStoreError::Io {
114                path: parent.to_path_buf(),
115                source,
116            })?;
117        }
118        if !path.exists() {
119            fs::write(&path, &bytes).map_err(|source| AttachmentStoreError::Io {
120                path: path.clone(),
121                source,
122            })?;
123        }
124        let meta_path = self.meta_path_for_id(&meta.id);
125        let meta_bytes = serde_json::to_vec_pretty(&meta).expect("attachment metadata serializes");
126        fs::write(&meta_path, meta_bytes).map_err(|source| AttachmentStoreError::Io {
127            path: meta_path.clone(),
128            source,
129        })?;
130        let reference = meta.as_ref();
131        self.meta
132            .lock()
133            .expect("attachment metadata lock")
134            .insert(reference.id.clone(), meta);
135        Ok(reference)
136    }
137
138    fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
139        let path = self.path_for_id(id);
140        let bytes = fs::read(&path).map_err(|source| {
141            if source.kind() == std::io::ErrorKind::NotFound {
142                AttachmentStoreError::NotFound(id.clone())
143            } else {
144                AttachmentStoreError::Io {
145                    path: path.clone(),
146                    source,
147                }
148            }
149        })?;
150        let meta = if let Some(meta) = self
151            .meta
152            .lock()
153            .expect("attachment metadata lock")
154            .get(id)
155            .cloned()
156        {
157            meta
158        } else {
159            let meta_path = self.meta_path_for_id(id);
160            let meta_bytes = fs::read(&meta_path).map_err(|source| {
161                if source.kind() == std::io::ErrorKind::NotFound {
162                    AttachmentStoreError::MissingMeta(id.clone())
163                } else {
164                    AttachmentStoreError::Io {
165                        path: meta_path.clone(),
166                        source,
167                    }
168                }
169            })?;
170            serde_json::from_slice(&meta_bytes).map_err(|source| AttachmentStoreError::Io {
171                path: meta_path,
172                source: std::io::Error::new(std::io::ErrorKind::InvalidData, source),
173            })?
174        };
175        Ok(StoredAttachment { meta, bytes })
176    }
177}
178
179pub fn content_id(bytes: &[u8]) -> AttachmentId {
180    AttachmentId::new(format!("{:x}", Sha256::digest(bytes)))
181}
182
183fn stored_meta(bytes: &[u8], meta: AttachmentCreateMeta) -> AttachmentMeta {
184    AttachmentMeta::new(
185        content_id(bytes),
186        meta.media_type,
187        bytes.len() as u64,
188        meta.width,
189        meta.height,
190        meta.label,
191    )
192}
193
194pub fn resolve_llm_request_attachments(
195    mut request: crate::llm::types::LlmRequest,
196    store: &dyn AttachmentStore,
197) -> Result<crate::llm::types::LlmRequest, AttachmentStoreError> {
198    for attachment in &mut request.attachments {
199        let Some(reference) = attachment.reference.as_ref() else {
200            continue;
201        };
202        if !attachment.data.is_empty() {
203            continue;
204        }
205        let stored = store.get(&reference.id)?;
206        attachment.mime = stored.meta.media_type.canonical_mime().to_string();
207        attachment.data = stored.bytes;
208    }
209    Ok(request)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use lash_sansio::{ImageMediaType, MediaType};
216
217    fn meta() -> AttachmentCreateMeta {
218        AttachmentCreateMeta::new(
219            MediaType::Image(ImageMediaType::Png),
220            Some(1),
221            Some(1),
222            Some("pixel".to_string()),
223        )
224    }
225
226    #[test]
227    fn memory_store_dedupes_by_bytes() {
228        let store = InMemoryAttachmentStore::new();
229        let a = store.put(vec![1, 2, 3], meta()).expect("put a");
230        let b = store.put(vec![1, 2, 3], meta()).expect("put b");
231        assert_eq!(a.id, b.id);
232        assert_eq!(a.byte_len, 3);
233        assert_eq!(store.get(&a.id).expect("get").bytes, vec![1, 2, 3]);
234    }
235
236    #[test]
237    fn memory_store_assigns_identity_and_byte_len_from_bytes() {
238        let store = InMemoryAttachmentStore::new();
239        let reference = store.put(vec![4, 5, 6, 7], meta()).expect("put");
240
241        assert_eq!(reference.id, content_id(&[4, 5, 6, 7]));
242        assert_eq!(reference.byte_len, 4);
243    }
244
245    #[test]
246    fn file_store_reads_after_write() {
247        let temp = tempfile::tempdir().expect("tempdir");
248        let store = FileAttachmentStore::new(temp.path());
249        let reference = store.put(vec![9, 8, 7], meta()).expect("put");
250        let stored = store.get(&reference.id).expect("get");
251        assert_eq!(stored.bytes, vec![9, 8, 7]);
252        assert_eq!(stored.meta.label.as_deref(), Some("pixel"));
253    }
254}