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/// Detects MIME type from Content-Type header
29pub fn detect_mime_type_from_content_type(content_type: &str) -> Option<String> {
30    let content_type = content_type.to_lowercase();
31    if content_type.starts_with("image/png") {
32        Some("image/png".to_string())
33    } else if content_type.starts_with("image/jpeg") || content_type.starts_with("image/jpg") {
34        Some("image/jpeg".to_string())
35    } else if content_type.starts_with("image/gif") {
36        Some("image/gif".to_string())
37    } else if content_type.starts_with("image/webp") {
38        Some("image/webp".to_string())
39    } else if content_type.starts_with("image/bmp") {
40        Some("image/bmp".to_string())
41    } else if content_type.starts_with("image/tiff") || content_type.starts_with("image/tif") {
42        Some("image/tiff".to_string())
43    } else if content_type.starts_with("image/svg") {
44        Some("image/svg+xml".to_string())
45    } else {
46        None
47    }
48}
49
50/// Detects MIME type from file data (magic bytes)
51pub fn detect_mime_type_from_data(data: &[u8]) -> String {
52    // JPEG magic bytes: starts with FF D8
53    if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
54        return "image/jpeg".to_string();
55    }
56
57    // Need at least 8 bytes for other formats
58    if data.len() < 8 {
59        return "image/png".to_string();
60    }
61
62    match &data[..8] {
63        [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => "image/png".to_string(),
64        [0x47, 0x49, 0x46, 0x38, _, _, _, _] => {
65            if data.len() >= 12 && &data[8..12] == b"WEBP" {
66                "image/webp".to_string()
67            } else {
68                "image/gif".to_string()
69            }
70        }
71        [0x52, 0x49, 0x46, 0x46, _, _, _, _] => {
72            if data.len() >= 12 && &data[8..12] == b"WEBP" {
73                "image/webp".to_string()
74            } else {
75                "image/png".to_string()
76            }
77        }
78        [0x42, 0x4D, _, _] => "image/bmp".to_string(),
79        _ => "image/png".to_string(),
80    }
81}
82
83/// Detects the MIME type based on file extension
84fn detect_mime_type_from_extension(path: &Path) -> Result<String> {
85    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
86
87    let mime_type = match extension.as_str() {
88        "png" => "image/png",
89        "jpg" | "jpeg" => "image/jpeg",
90        "gif" => "image/gif",
91        "webp" => "image/webp",
92        "bmp" => "image/bmp",
93        "tiff" | "tif" => "image/tiff",
94        "svg" => "image/svg+xml",
95        _ => return Err(anyhow::anyhow!("Unsupported image format: {extension}")),
96    };
97
98    Ok(mime_type.to_string())
99}
100
101/// Validates that the image file path has a supported extension
102pub fn has_supported_image_extension(path: &Path) -> bool {
103    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
104
105    const VALID_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "svg"];
106    VALID_EXTENSIONS.contains(&extension.as_str())
107}
108
109/// Encodes binary data to base64
110pub fn encode_to_base64(data: &[u8]) -> String {
111    base64::engine::general_purpose::STANDARD.encode(data)
112}
113
114/// Maximum accepted image file size (20 MB).
115pub const MAX_IMAGE_FILE_BYTES: u64 = 20 * 1024 * 1024;
116
117fn image_too_large_error(len: u64) -> anyhow::Error {
118    anyhow::anyhow!("Image file too large: {len} bytes (max {}MB)", MAX_IMAGE_FILE_BYTES / (1024 * 1024))
119}
120
121/// Read an image file into base64 form inside one blocking segment.
122///
123/// Stat, size check, read, and base64 encoding all run in a single
124/// `spawn_blocking` hop instead of chained `tokio::fs` calls plus an on-worker
125/// encode of up to 20 MB. Batching here keeps the shared blocking pool from
126/// seeing two round-trips per file and keeps the base64 encode off the runtime
127/// workers (fast-Tokio: batch blocking work, avoid long polls).
128async fn read_image_file_blocking(path: &Path) -> Result<ImageData> {
129    let owned_path = path.to_path_buf();
130    tokio::task::spawn_blocking(move || read_image_file_blocking_inner(&owned_path))
131        .await
132        .context("image read task failed")?
133}
134
135fn read_image_file_blocking_inner(path: &Path) -> Result<ImageData> {
136    // Fail fast on oversized regular files before reading them into memory.
137    // `metadata.len()` is 0 for pipes/character devices, so those still fall
138    // through to the read + post-read size check below.
139    if let Ok(metadata) = std::fs::metadata(path)
140        && metadata.is_file()
141        && metadata.len() > MAX_IMAGE_FILE_BYTES
142    {
143        return Err(image_too_large_error(metadata.len()));
144    }
145
146    let file_contents =
147        std::fs::read(path).with_context(|| format!("Failed to read image file: {}", path.display()))?;
148
149    if file_contents.len() as u64 > MAX_IMAGE_FILE_BYTES {
150        return Err(image_too_large_error(file_contents.len() as u64));
151    }
152
153    let mime_type = detect_mime_type_from_extension(path)?;
154    Ok(ImageData {
155        base64_data: encode_to_base64(&file_contents),
156        mime_type,
157        file_path: path.display().to_string(),
158        size: file_contents.len() as u64,
159    })
160}
161
162/// Reads an image file from the local filesystem and converts it to base64 format.
163///
164/// Validates the path for traversal attacks and checks the file extension
165/// against a supported set. Max file size is 20 MB.
166pub async fn read_image_file<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
167    use crate::paths::is_safe_relative_path;
168
169    let path = file_path.as_ref();
170
171    if !is_safe_relative_path(&path.to_string_lossy()) {
172        return Err(anyhow::anyhow!("Unsafe or traversal detected in image path: {}", path.display()));
173    }
174
175    if !has_supported_image_extension(path) {
176        return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
177    }
178
179    read_image_file_blocking(path).await
180}
181
182/// Reads an image file from an absolute path (or already validated path) and
183/// converts it to base64.
184///
185/// This skips relative-path safety checks and should only be used when the
186/// caller has already validated the path scope and intent.
187pub async fn read_image_file_any_path<P: AsRef<Path>>(file_path: P) -> Result<ImageData> {
188    let path = file_path.as_ref();
189
190    if !has_supported_image_extension(path) {
191        return Err(anyhow::anyhow!("Unsupported image extension for path: {}", path.display()));
192    }
193
194    read_image_file_blocking(path).await
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use base64::Engine as _;
201
202    const PNG_MAGIC: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
203
204    #[tokio::test]
205    async fn read_image_file_returns_encoded_bytes_and_mime() {
206        let dir = tempfile::tempdir().expect("tempdir");
207        let path = dir.path().join("pixel.png");
208        std::fs::write(&path, PNG_MAGIC).expect("write image");
209
210        let data = read_image_file_any_path(&path).await.expect("read image");
211
212        assert_eq!(data.mime_type, "image/png");
213        assert_eq!(data.size, PNG_MAGIC.len() as u64);
214        let decoded = base64::engine::general_purpose::STANDARD
215            .decode(&data.base64_data)
216            .expect("valid base64");
217        assert_eq!(decoded, PNG_MAGIC);
218    }
219
220    #[tokio::test]
221    async fn read_image_file_rejects_oversized_file_before_encoding() {
222        let dir = tempfile::tempdir().expect("tempdir");
223        let path = dir.path().join("huge.png");
224        // Sparse file just over the cap: rejected by the metadata guard so the
225        // contents are never read or encoded.
226        let file = std::fs::File::create(&path).expect("create image");
227        file.set_len(MAX_IMAGE_FILE_BYTES + 1).expect("set length");
228        drop(file);
229
230        let err = read_image_file_any_path(&path)
231            .await
232            .expect_err("oversized image must be rejected");
233        assert!(err.to_string().contains("too large"), "unexpected error: {err}");
234    }
235
236    #[tokio::test]
237    async fn read_image_file_any_path_rejects_unsupported_extension() {
238        let err = read_image_file_any_path("notes.txt")
239            .await
240            .expect_err("unsupported extension must be rejected");
241        assert!(err.to_string().contains("Unsupported image extension"));
242    }
243
244    #[test]
245    fn detect_mime_type_from_data_recognizes_png_magic() {
246        assert_eq!(detect_mime_type_from_data(PNG_MAGIC), "image/png");
247    }
248}