1#![expect(
2 clippy::indexing_slicing,
3 reason = "Image signatures are checked for minimum length before fixed-format byte access."
4)]
5
6use anyhow::{Context, Result};
9use base64::Engine;
10use std::path::Path;
11
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct ImageData {
15 pub base64_data: String,
17
18 pub mime_type: String,
20
21 pub file_path: String,
23
24 pub size: u64,
26}
27
28pub const SUPPORTED_IMAGE_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
35
36pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
38
39pub const UNSUPPORTED_BINARY_IMAGE_EXTENSIONS: &[&str] = &["bmp", "tif", "tiff"];
45
46pub fn is_supported_image_mime_type(mime_type: &str) -> bool {
48 SUPPORTED_IMAGE_MIME_TYPES.contains(&mime_type)
49}
50
51pub fn detect_mime_type_from_content_type(content_type: &str) -> Option<String> {
57 let content_type = content_type.to_lowercase();
58 if content_type.starts_with("image/png") {
59 Some("image/png".to_string())
60 } else if content_type.starts_with("image/jpeg") || content_type.starts_with("image/jpg") {
61 Some("image/jpeg".to_string())
62 } else if content_type.starts_with("image/gif") {
63 Some("image/gif".to_string())
64 } else if content_type.starts_with("image/webp") {
65 Some("image/webp".to_string())
66 } else if content_type.starts_with("image/bmp") {
67 Some("image/bmp".to_string())
68 } else if content_type.starts_with("image/tiff") || content_type.starts_with("image/tif") {
69 Some("image/tiff".to_string())
70 } else if content_type.starts_with("image/svg") {
71 Some("image/svg+xml".to_string())
72 } else {
73 None
74 }
75}
76
77pub fn detect_mime_type_from_data(data: &[u8]) -> String {
79 if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
81 return "image/jpeg".to_string();
82 }
83
84 if data.len() < 8 {
86 return "image/png".to_string();
87 }
88
89 match &data[..8] {
90 [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => "image/png".to_string(),
91 [0x47, 0x49, 0x46, 0x38, _, _, _, _] => {
92 if data.len() >= 12 && &data[8..12] == b"WEBP" {
93 "image/webp".to_string()
94 } else {
95 "image/gif".to_string()
96 }
97 }
98 [0x52, 0x49, 0x46, 0x46, _, _, _, _] => {
99 if data.len() >= 12 && &data[8..12] == b"WEBP" {
100 "image/webp".to_string()
101 } else {
102 "image/png".to_string()
103 }
104 }
105 [0x42, 0x4D, _, _] => "image/bmp".to_string(),
106 _ => "image/png".to_string(),
107 }
108}
109
110fn detect_mime_type_from_extension(path: &Path) -> Result<String> {
116 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
117
118 let mime_type = match extension.as_str() {
119 "png" => "image/png",
120 "jpg" | "jpeg" => "image/jpeg",
121 "gif" => "image/gif",
122 "webp" => "image/webp",
123 _ => return Err(anyhow::anyhow!("Unsupported image format: {extension}")),
124 };
125
126 Ok(mime_type.to_string())
127}
128
129pub fn has_supported_image_extension(path: &Path) -> bool {
134 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
135
136 SUPPORTED_IMAGE_EXTENSIONS.contains(&extension.as_str())
137}
138
139pub fn encode_to_base64(data: &[u8]) -> String {
141 base64::engine::general_purpose::STANDARD.encode(data)
142}
143
144pub const MAX_IMAGE_FILE_BYTES: u64 = 20 * 1024 * 1024;
146
147fn image_too_large_error(len: u64) -> anyhow::Error {
148 anyhow::anyhow!("Image file too large: {len} bytes (max {}MB)", MAX_IMAGE_FILE_BYTES / (1024 * 1024))
149}
150
151async fn read_image_file_blocking(path: &Path) -> Result<ImageData> {
159 let owned_path = path.to_path_buf();
160 tokio::task::spawn_blocking(move || read_image_file_blocking_inner(&owned_path))
161 .await
162 .context("image read task failed")?
163}
164
165fn read_image_file_blocking_inner(path: &Path) -> Result<ImageData> {
166 if let Ok(metadata) = std::fs::metadata(path)
170 && metadata.is_file()
171 && metadata.len() > MAX_IMAGE_FILE_BYTES
172 {
173 return Err(image_too_large_error(metadata.len()));
174 }
175
176 let file_contents =
177 std::fs::read(path).with_context(|| format!("Failed to read image file: {}", path.display()))?;
178
179 if file_contents.len() as u64 > MAX_IMAGE_FILE_BYTES {
180 return Err(image_too_large_error(file_contents.len() as u64));
181 }
182
183 let mime_type = detect_mime_type_from_extension(path)?;
184 Ok(ImageData {
185 base64_data: encode_to_base64(&file_contents),
186 mime_type,
187 file_path: path.display().to_string(),
188 size: file_contents.len() as u64,
189 })
190}
191
192pub async fn read_image_file<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
197 use crate::paths::is_safe_relative_path;
198
199 let path = file_path.as_ref();
200
201 if !is_safe_relative_path(&path.to_string_lossy()) {
202 return Err(anyhow::anyhow!("Unsafe or traversal detected in image path: {}", path.display()));
203 }
204
205 if !has_supported_image_extension(path) {
206 return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
207 }
208
209 read_image_file_blocking(path).await
210}
211
212pub async fn read_image_file_any_path<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
218 let path = file_path.as_ref();
219
220 if !has_supported_image_extension(path) {
221 return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
222 }
223
224 read_image_file_blocking(path).await
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use base64::Engine as _;
231
232 const PNG_MAGIC: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
233
234 #[tokio::test]
235 async fn read_image_file_returns_encoded_bytes_and_mime() {
236 let dir = tempfile::tempdir().expect("tempdir");
237 let path = dir.path().join("pixel.png");
238 std::fs::write(&path, PNG_MAGIC).expect("write image");
239
240 let data = read_image_file_any_path(&path).await.expect("read image");
241
242 assert_eq!(data.mime_type, "image/png");
243 assert_eq!(data.size, PNG_MAGIC.len() as u64);
244 let decoded = base64::engine::general_purpose::STANDARD
245 .decode(&data.base64_data)
246 .expect("valid base64");
247 assert_eq!(decoded, PNG_MAGIC);
248 }
249
250 #[tokio::test]
251 async fn read_image_file_rejects_oversized_file_before_encoding() {
252 let dir = tempfile::tempdir().expect("tempdir");
253 let path = dir.path().join("huge.png");
254 let file = std::fs::File::create(&path).expect("create image");
257 file.set_len(MAX_IMAGE_FILE_BYTES + 1).expect("set length");
258 drop(file);
259
260 let err = read_image_file_any_path(&path)
261 .await
262 .expect_err("oversized image must be rejected");
263 assert!(err.to_string().contains("too large"), "unexpected error: {err}");
264 }
265
266 #[tokio::test]
267 async fn read_image_file_any_path_rejects_unsupported_extension() {
268 let err = read_image_file_any_path("notes.txt")
269 .await
270 .expect_err("unsupported extension must be rejected");
271 assert!(err.to_string().contains("Unsupported image extension"));
272 }
273
274 #[test]
275 fn detect_mime_type_from_data_recognizes_png_magic() {
276 assert_eq!(detect_mime_type_from_data(PNG_MAGIC), "image/png");
277 }
278
279 #[test]
280 fn only_provider_supported_extensions_count_as_images() {
281 for supported in [
282 "pixel.png",
283 "photo.jpg",
284 "photo.jpeg",
285 "anim.gif",
286 "modern.webp",
287 "UPPER.PNG",
288 ] {
289 assert!(has_supported_image_extension(Path::new(supported)), "{supported} should be a supported image");
290 }
291 for unsupported in ["vector.svg", "bitmap.bmp", "scan.tif", "scan.tiff", "notes.txt"] {
294 assert!(
295 !has_supported_image_extension(Path::new(unsupported)),
296 "{unsupported} must not be treated as a vision image"
297 );
298 }
299 }
300
301 #[test]
302 fn only_provider_supported_mimes_pass_the_allowlist() {
303 for mime in ["image/jpeg", "image/png", "image/gif", "image/webp"] {
304 assert!(is_supported_image_mime_type(mime), "{mime} should be supported");
305 }
306 for mime in ["image/svg+xml", "image/bmp", "image/tiff", "image/heic", "text/plain"] {
307 assert!(!is_supported_image_mime_type(mime), "{mime} must be rejected");
308 }
309 }
310
311 #[test]
312 fn extension_mime_detection_rejects_formats_providers_refuse() {
313 assert_eq!(detect_mime_type_from_extension(Path::new("a.png")).expect("png"), "image/png");
314 for rejected in ["a.svg", "a.bmp", "a.tif", "a.tiff"] {
315 assert!(detect_mime_type_from_extension(Path::new(rejected)).is_err(), "{rejected} must be rejected");
316 }
317 }
318
319 #[tokio::test]
320 async fn read_image_file_any_path_rejects_svg_and_bmp() {
321 let dir = tempfile::tempdir().expect("tempdir");
322 for name in ["logo.svg", "bitmap.bmp"] {
323 let path = dir.path().join(name);
324 std::fs::write(&path, b"<svg></svg>").expect("write fixture");
325 let err = read_image_file_any_path(&path).await.expect_err("must be rejected");
326 assert!(err.to_string().contains("Unsupported image extension"), "unexpected error: {err}");
327 }
328 }
329}