Skip to main content

vtcode_commons/
image.rs

1#![expect(
2    clippy::indexing_slicing,
3    reason = "Image signatures are checked for minimum length before fixed-format byte access."
4)]
5
6//! Image processing utilities
7
8use anyhow::{Context, Result};
9use base64::Engine;
10use std::path::Path;
11
12/// Represents the data from an image file ready for LLM consumption
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct ImageData {
15    /// Base64-encoded image data
16    pub base64_data: String,
17
18    /// MIME type of the image (e.g., "image/png", "image/jpeg")
19    pub mime_type: String,
20
21    /// Original file path or URL
22    pub file_path: String,
23
24    /// File size in bytes
25    pub size: u64,
26}
27
28/// Image MIME types accepted by LLM vision APIs.
29///
30/// OpenAI, Anthropic, Gemini, and DeepSeek all accept exactly this set for
31/// base64 image inputs. Formats outside it (BMP, TIFF, SVG, ...) are rejected
32/// by providers with `400 invalid_value`, so they must never be serialized as
33/// `input_image` parts. SVG is XML text and is better read as text.
34pub const SUPPORTED_IMAGE_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
35
36/// File extensions that map to [`SUPPORTED_IMAGE_MIME_TYPES`].
37pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
38
39/// Binary raster extensions no LLM vision API accepts.
40///
41/// These must be rejected explicitly instead of falling through to a text
42/// read (which would produce mojibake). SVG is intentionally absent: it is
43/// text and reads fine through the normal text path.
44pub const UNSUPPORTED_BINARY_IMAGE_EXTENSIONS: &[&str] = &["bmp", "tif", "tiff"];
45
46/// Returns whether a MIME type can be sent to LLM vision APIs.
47pub fn is_supported_image_mime_type(mime_type: &str) -> bool {
48    SUPPORTED_IMAGE_MIME_TYPES.contains(&mime_type)
49}
50
51/// Detects MIME type from Content-Type header.
52///
53/// This stays truthful about BMP/TIFF/SVG content: callers must gate the
54/// result through [`is_supported_image_mime_type`] before serializing an
55/// image part, otherwise providers reject the request.
56pub 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
77/// Detects MIME type from file data (magic bytes)
78pub fn detect_mime_type_from_data(data: &[u8]) -> String {
79    // JPEG magic bytes: starts with FF D8
80    if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
81        return "image/jpeg".to_string();
82    }
83
84    // Need at least 8 bytes for other formats
85    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
110/// Detects the MIME type based on file extension.
111///
112/// Only extensions in [`SUPPORTED_IMAGE_EXTENSIONS`] are accepted; anything
113/// else (including BMP/TIFF/SVG) is an error so callers fall back to text or
114/// surface a clear rejection instead of sending a payload providers refuse.
115fn 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
129/// Validates that the image file path has a provider-supported extension.
130///
131/// BMP/TIFF/SVG are intentionally excluded: no LLM vision API accepts them,
132/// and sending them produces `400 invalid_value` errors.
133pub 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
139/// Encodes binary data to base64
140pub fn encode_to_base64(data: &[u8]) -> String {
141    base64::engine::general_purpose::STANDARD.encode(data)
142}
143
144/// Maximum accepted image file size (20 MB).
145pub 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
151/// Read an image file into base64 form inside one blocking segment.
152///
153/// Stat, size check, read, and base64 encoding all run in a single
154/// `spawn_blocking` hop instead of chained `tokio::fs` calls plus an on-worker
155/// encode of up to 20 MB. Batching here keeps the shared blocking pool from
156/// seeing two round-trips per file and keeps the base64 encode off the runtime
157/// workers (fast-Tokio: batch blocking work, avoid long polls).
158async 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    // Fail fast on oversized regular files before reading them into memory.
167    // `metadata.len()` is 0 for pipes/character devices, so those still fall
168    // through to the read + post-read size check below.
169    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
192/// Reads an image file from the local filesystem and converts it to base64 format.
193///
194/// Validates the path for traversal attacks and checks the file extension
195/// against a supported set. Max file size is 20 MB.
196pub 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
212/// Reads an image file from an absolute path (or already validated path) and
213/// converts it to base64.
214///
215/// This skips relative-path safety checks and should only be used when the
216/// caller has already validated the path scope and intent.
217pub 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        // Sparse file just over the cap: rejected by the metadata guard so the
255        // contents are never read or encoded.
256        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        // BMP/TIFF/SVG are refused by every LLM vision API (400 invalid_value),
292        // so they must never become image parts.
293        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}