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};
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 debug!("Reading image: {}", file_path);
54
55 let file_path = expand_user(file_path);
57
58 let path = if Path::new(&file_path).is_absolute() {
60 PathBuf::from(&file_path)
61 } else {
62 cwd.join(&file_path)
64 };
65
66 let path = validate_path_in_workspace(&path, workspace_root)
71 .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
72
73 if !path.is_file() {
75 return Err(WinxError::FileAccessError {
76 path: path.clone(),
77 message: "File does not exist or is not a regular file".to_string(),
78 });
79 }
80
81 let size = std::fs::metadata(&path).map_or(0, |m| m.len());
83 if size > MAX_IMAGE_BYTES {
84 return Err(WinxError::FileAccessError {
85 path: path.clone(),
86 message: format!("Image too large: {size} bytes (max {MAX_IMAGE_BYTES} bytes)"),
87 });
88 }
89
90 let image_bytes = std::fs::read(&path).map_err(|e| WinxError::FileAccessError {
92 path: path.clone(),
93 message: format!("Error reading file: {e}"),
94 })?;
95
96 let image_b64 = general_purpose::STANDARD.encode(&image_bytes);
98
99 let mime_type =
101 MimeGuess::from_path(&path).first_raw().unwrap_or("application/octet-stream").to_string();
102
103 if SUPPORTED_MIME_TYPES.contains(&mime_type.as_str()) {
105 Ok((mime_type, image_b64))
106 } else {
107 debug!("Detected MIME type '{}' is not in the supported list", mime_type);
108 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase();
110
111 let mime_type = match extension.as_str() {
112 "png" => "image/png",
113 "gif" => "image/gif",
114 "webp" => "image/webp",
115 _ => "image/jpeg", };
117
118 debug!("Using fallback MIME type: {}", mime_type);
119 Ok((mime_type.to_string(), image_b64))
120 }
121}
122
123#[instrument(level = "info", skip(bash_state_arc, read_image))]
141pub async fn handle_tool_call(
142 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
143 read_image: ReadImage,
144) -> Result<(String, String)> {
145 info!("ReadImage tool called with: {:?}", read_image);
146
147 let cwd: PathBuf;
150 let workspace_root: PathBuf;
151
152 {
154 let bash_state_guard = bash_state_arc.lock().await;
155
156 let Some(bash_state) = &*bash_state_guard else {
158 error!("BashState not initialized");
159 return Err(WinxError::BashStateNotInitialized);
160 };
161
162 cwd = bash_state.cwd.clone();
164 workspace_root = bash_state.workspace_root.clone();
165 }
166
167 read_image_from_path(&read_image.file_path, &cwd, &workspace_root)
169}
170
171#[cfg(test)]
172mod tests {
173 #![allow(clippy::unwrap_used)]
174 use super::*;
175 use std::fs;
176 use tempfile::TempDir;
177
178 #[test]
179 fn reads_image_inside_workspace() {
180 let ws = TempDir::new().unwrap();
181 let img = ws.path().join("shot.png");
182 fs::write(&img, b"\x89PNG\r\n\x1a\nfake").unwrap();
183 let (mime, b64) =
184 read_image_from_path(img.to_str().unwrap(), ws.path(), ws.path()).unwrap();
185 assert_eq!(mime, "image/png");
186 assert!(!b64.is_empty());
187 }
188
189 #[test]
190 fn rejects_image_outside_workspace() {
191 let ws = TempDir::new().unwrap();
194 let outside = TempDir::new().unwrap();
195 let secret = outside.path().join("secret.png");
196 fs::write(&secret, b"\x89PNG secret").unwrap();
197 let err = read_image_from_path(secret.to_str().unwrap(), ws.path(), ws.path());
198 assert!(matches!(err, Err(WinxError::PathSecurityError { .. })));
199 }
200}