1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use base64::Engine;
5
6use crate::error::RuntimeError;
7use crate::message::{ImageData, ImageSource};
8
9const ATTACHMENTS_DIR: &str = "attachments";
10const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
11
12#[derive(Debug, Clone)]
13pub struct AttachmentStore {
14 root: PathBuf,
15 persistent: bool,
16}
17
18impl AttachmentStore {
19 pub fn at(session_dir: impl AsRef<Path>) -> Self {
20 let session_dir = session_dir.as_ref();
21 Self {
22 root: session_dir.join(ATTACHMENTS_DIR),
23 persistent: !session_dir.as_os_str().is_empty(),
24 }
25 }
26
27 pub fn import_path(&self, path: impl AsRef<Path>) -> Result<ImageSource, RuntimeError> {
28 let path = path.as_ref();
29 let bytes = std::fs::read(path).map_err(|error| RuntimeError::AttachmentError {
30 reason: format!("cannot read {}: {error}", path.display()),
31 })?;
32 self.import_bytes(&bytes, path.file_name().and_then(|name| name.to_str()))
33 }
34
35 pub fn import_base64(
36 &self,
37 data: &str,
38 name: Option<&str>,
39 ) -> Result<ImageSource, RuntimeError> {
40 let bytes = base64::engine::general_purpose::STANDARD
41 .decode(data)
42 .map_err(|error| RuntimeError::AttachmentError {
43 reason: format!("invalid base64 image: {error}"),
44 })?;
45 self.import_bytes(&bytes, name)
46 }
47
48 pub fn import_bytes(
49 &self,
50 bytes: &[u8],
51 name: Option<&str>,
52 ) -> Result<ImageSource, RuntimeError> {
53 validate_size(bytes)?;
54 let (media_type, extension) = detect_image_type(bytes)?;
55 if !self.persistent {
56 return Ok(ImageSource {
57 media_type: media_type.into(),
58 data: ImageData::Base64 {
59 data: base64::engine::general_purpose::STANDARD.encode(bytes),
60 },
61 detail: crate::provider::ImageDetail::Auto,
62 });
63 }
64 let id = blake3::hash(bytes).to_hex().to_string();
65 let path = self.root.join(format!("{id}.{extension}"));
66 if !path.is_file() {
67 std::fs::create_dir_all(&self.root).map_err(|error| RuntimeError::AttachmentError {
68 reason: format!("cannot create attachment store: {error}"),
69 })?;
70 let temp_path = self
71 .root
72 .join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4()));
73 let write_result = (|| -> std::io::Result<()> {
74 let mut file = std::fs::OpenOptions::new()
75 .create_new(true)
76 .write(true)
77 .open(&temp_path)?;
78 file.write_all(bytes)?;
79 file.sync_all()?;
80 std::fs::rename(&temp_path, &path)
81 })();
82 if let Err(error) = write_result {
83 let _ = std::fs::remove_file(&temp_path);
84 if !path.is_file() {
85 return Err(RuntimeError::AttachmentError {
86 reason: format!("cannot persist attachment: {error}"),
87 });
88 }
89 }
90 }
91 Ok(ImageSource {
92 media_type: media_type.into(),
93 data: ImageData::Artifact {
94 id,
95 path,
96 name: name.map(ToOwned::to_owned),
97 },
98 detail: crate::provider::ImageDetail::Auto,
99 })
100 }
101}
102
103pub fn image_bytes(source: &ImageSource) -> Result<Vec<u8>, RuntimeError> {
104 let bytes = match &source.data {
105 ImageData::Base64 { data } => base64::engine::general_purpose::STANDARD
106 .decode(data)
107 .map_err(|error| RuntimeError::AttachmentError {
108 reason: format!("invalid base64 image: {error}"),
109 })?,
110 ImageData::Path { path } | ImageData::Artifact { path, .. } => std::fs::read(path)
111 .map_err(|error| RuntimeError::AttachmentError {
112 reason: format!("cannot read {}: {error}", path.display()),
113 })?,
114 };
115 validate_size(&bytes)?;
116 let (actual_media_type, _) = detect_image_type(&bytes)?;
117 if source.media_type != actual_media_type {
118 return Err(RuntimeError::AttachmentError {
119 reason: format!(
120 "image media type mismatch: declared {}, detected {actual_media_type}",
121 source.media_type
122 ),
123 });
124 }
125 if let ImageData::Artifact { id, .. } = &source.data {
126 let actual_id = blake3::hash(&bytes).to_hex().to_string();
127 if actual_id != *id {
128 return Err(RuntimeError::AttachmentError {
129 reason: format!("attachment integrity check failed for {id}"),
130 });
131 }
132 }
133 Ok(bytes)
134}
135
136pub fn image_base64(source: &ImageSource) -> Result<String, RuntimeError> {
137 if let ImageData::Base64 { data } = &source.data {
138 image_bytes(source)?;
139 return Ok(data.clone());
140 }
141 Ok(base64::engine::general_purpose::STANDARD.encode(image_bytes(source)?))
142}
143
144pub fn display_name(source: &ImageSource) -> String {
145 match &source.data {
146 ImageData::Artifact { id, name, .. } => name.clone().unwrap_or_else(|| id.clone()),
147 ImageData::Path { path } => path
148 .file_name()
149 .and_then(|name| name.to_str())
150 .unwrap_or("image")
151 .to_string(),
152 ImageData::Base64 { .. } => "image".into(),
153 }
154}
155
156fn validate_size(bytes: &[u8]) -> Result<(), RuntimeError> {
157 if bytes.is_empty() {
158 return Err(RuntimeError::AttachmentError {
159 reason: "image is empty".into(),
160 });
161 }
162 if bytes.len() > MAX_IMAGE_BYTES {
163 return Err(RuntimeError::AttachmentError {
164 reason: format!(
165 "image is too large: {} bytes exceeds the {} byte limit",
166 bytes.len(),
167 MAX_IMAGE_BYTES
168 ),
169 });
170 }
171 Ok(())
172}
173
174fn detect_image_type(bytes: &[u8]) -> Result<(&'static str, &'static str), RuntimeError> {
175 let detected = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
176 Some(("image/png", "png"))
177 } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
178 Some(("image/jpeg", "jpg"))
179 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
180 Some(("image/gif", "gif"))
181 } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
182 Some(("image/webp", "webp"))
183 } else {
184 None
185 };
186 detected.ok_or_else(|| RuntimeError::AttachmentError {
187 reason: "unsupported image format; expected PNG, JPEG, GIF, or WebP".into(),
188 })
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 const PNG_1X1: &[u8] = &[
196 0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
197 ];
198
199 #[test]
200 fn import_is_content_addressed_and_deduplicated() {
201 let session = tempfile::tempdir().unwrap();
202 let store = AttachmentStore::at(session.path());
203 let first = store.import_bytes(PNG_1X1, Some("first.png")).unwrap();
204 let second = store.import_bytes(PNG_1X1, Some("second.png")).unwrap();
205
206 let ImageData::Artifact {
207 id: first_id,
208 path: first_path,
209 ..
210 } = first.data
211 else {
212 panic!("expected artifact")
213 };
214 let ImageData::Artifact {
215 id: second_id,
216 path: second_path,
217 ..
218 } = second.data
219 else {
220 panic!("expected artifact")
221 };
222 assert_eq!(first_id, second_id);
223 assert_eq!(first_path, second_path);
224 assert_eq!(std::fs::read(first_path).unwrap(), PNG_1X1);
225 }
226
227 #[test]
228 fn integrity_mismatch_is_rejected() {
229 let session = tempfile::tempdir().unwrap();
230 let store = AttachmentStore::at(session.path());
231 let source = store.import_bytes(PNG_1X1, None).unwrap();
232 let ImageData::Artifact { path, .. } = &source.data else {
233 panic!("expected artifact")
234 };
235 std::fs::write(path, b"not an image").unwrap();
236 assert!(image_bytes(&source).is_err());
237 }
238}