winx_code_agent/tools/
read_image.rs1use base64::{engine::general_purpose, Engine};
8use mime_guess::MimeGuess;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio::sync::Mutex;
12use tracing::{debug, error, info, instrument};
13
14use crate::errors::{Result, WinxError};
15use crate::state::bash_state::BashState;
16use crate::types::ReadImage;
17use crate::utils::path::{expand_user, validate_path_in_workspace, validate_path_with_roots};
18
19pub const SUPPORTED_MIME_TYPES: [&str; 4] = ["image/jpeg", "image/png", "image/gif", "image/webp"];
21
22const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
27
28#[instrument(level = "debug", skip(file_path))]
48fn read_image_from_path(
49 file_path: &str,
50 cwd: &Path,
51 workspace_root: &Path,
52) -> Result<(String, String)> {
53 read_image_from_path_impl(file_path, cwd, workspace_root, None)
54}
55
56#[cfg(test)]
57fn read_image_from_path_with_roots(
58 file_path: &str,
59 cwd: &Path,
60 workspace_root: &Path,
61 extra_roots: &[PathBuf],
62) -> Result<(String, String)> {
63 read_image_from_path_impl(file_path, cwd, workspace_root, Some(extra_roots))
64}
65
66fn read_image_from_path_impl(
67 file_path: &str,
68 cwd: &Path,
69 workspace_root: &Path,
70 extra_roots: Option<&[PathBuf]>,
71) -> Result<(String, String)> {
72 debug!("Reading image: {}", file_path);
73
74 let file_path = expand_user(file_path);
76
77 let path = if Path::new(&file_path).is_absolute() {
79 PathBuf::from(&file_path)
80 } else {
81 cwd.join(&file_path)
83 };
84
85 let validated = match extra_roots {
90 Some(extra_roots) => validate_path_with_roots(&path, workspace_root, extra_roots),
91 None => validate_path_in_workspace(&path, workspace_root),
92 };
93 let path = validated
94 .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
95
96 if !path.is_file() {
98 return Err(WinxError::FileAccessError {
99 path: path.clone(),
100 message: "File does not exist or is not a regular file".to_string(),
101 });
102 }
103
104 let size = std::fs::metadata(&path).map_or(0, |m| m.len());
106 if size > MAX_IMAGE_BYTES {
107 return Err(WinxError::FileAccessError {
108 path: path.clone(),
109 message: format!("Image too large: {size} bytes (max {MAX_IMAGE_BYTES} bytes)"),
110 });
111 }
112
113 let image_bytes = std::fs::read(&path).map_err(|e| WinxError::FileAccessError {
115 path: path.clone(),
116 message: format!("Error reading file: {e}"),
117 })?;
118
119 let image_b64 = general_purpose::STANDARD.encode(&image_bytes);
121
122 let mime_type =
124 MimeGuess::from_path(&path).first_raw().unwrap_or("application/octet-stream").to_string();
125
126 if SUPPORTED_MIME_TYPES.contains(&mime_type.as_str()) {
128 Ok((mime_type, image_b64))
129 } else {
130 debug!("Detected MIME type '{}' is not in the supported list", mime_type);
131 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
133
134 let mime_type = match extension.as_str() {
135 "png" => "image/png",
136 "gif" => "image/gif",
137 "webp" => "image/webp",
138 _ => "image/jpeg", };
140
141 debug!("Using fallback MIME type: {}", mime_type);
142 Ok((mime_type.to_string(), image_b64))
143 }
144}
145
146#[instrument(level = "info", skip(bash_state_arc, read_image))]
164pub async fn handle_tool_call(
165 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
166 read_image: ReadImage,
167) -> Result<(String, String)> {
168 info!("ReadImage tool called with: {:?}", read_image);
169
170 let cwd: PathBuf;
173 let workspace_root: PathBuf;
174
175 {
177 let bash_state_guard = bash_state_arc.lock().await;
178
179 let Some(bash_state) = &*bash_state_guard else {
181 error!("BashState not initialized");
182 return Err(WinxError::BashStateNotInitialized);
183 };
184
185 cwd = bash_state.cwd.clone();
187 workspace_root = bash_state.workspace_root.clone();
188 }
189
190 let file_path = read_image.file_path;
193 tokio::task::spawn_blocking(move || read_image_from_path(&file_path, &cwd, &workspace_root))
194 .await
195 .map_err(|error| {
196 WinxError::CommandExecutionError(format!("ReadImage worker failed: {error}"))
197 })?
198}
199
200#[cfg(test)]
201mod tests {
202 #![allow(clippy::unwrap_used)]
203 use super::*;
204 use std::fs;
205 use tempfile::TempDir;
206
207 #[test]
208 fn reads_image_inside_workspace() {
209 let ws = TempDir::new().unwrap();
210 let img = ws.path().join("shot.png");
211 fs::write(&img, b"\x89PNG\r\n\x1a\nfake").unwrap();
212 let (mime, b64) =
213 read_image_from_path_with_roots(img.to_str().unwrap(), ws.path(), ws.path(), &[])
214 .unwrap();
215 assert_eq!(mime, "image/png");
216 assert!(!b64.is_empty());
217 }
218
219 #[test]
220 fn rejects_image_outside_workspace() {
221 let ws = TempDir::new().unwrap();
224 let outside = TempDir::new().unwrap();
225 let secret = outside.path().join("secret.png");
226 fs::write(&secret, b"\x89PNG secret").unwrap();
227 let err =
228 read_image_from_path_with_roots(secret.to_str().unwrap(), ws.path(), ws.path(), &[]);
229 assert!(matches!(err, Err(WinxError::PathSecurityError { .. })));
230 }
231}