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}