Skip to main content

winx_code_agent/tools/
read_image.rs

1//! Implementation of the `ReadImage` tool.
2//!
3//! This module provides the implementation for the `ReadImage` tool, which is used
4//! to read image files and return their contents as base64-encoded data with
5//! the appropriate MIME type.
6
7use 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
19/// Supported MIME types for images
20pub const SUPPORTED_MIME_TYPES: [&str; 4] = ["image/jpeg", "image/png", "image/gif", "image/webp"];
21
22/// Refuse to read images larger than this. base64 encoding inflates the
23/// footprint ~1.37×, so a few-hundred-MB file would spike to gigabytes; under
24/// `panic = "abort"` an allocation failure aborts the whole server. Real images
25/// (screenshots, mockups, error PNGs) are orders of magnitude smaller.
26const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
27
28/// Read an image from the file system
29///
30/// This function reads an image file, base64 encodes its contents, and
31/// determines the MIME type based on the file extension.
32///
33/// # Arguments
34///
35/// * `file_path` - Path to the image file
36/// * `cwd` - Current working directory for resolving relative paths
37///
38/// # Returns
39///
40/// A tuple containing:
41/// - The MIME type of the image
42/// - The base64-encoded image data
43///
44/// # Errors
45///
46/// Returns an error if the file cannot be accessed or read
47#[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    // Expand the path
56    let file_path = expand_user(file_path);
57
58    // Ensure path is absolute
59    let path = if Path::new(&file_path).is_absolute() {
60        PathBuf::from(&file_path)
61    } else {
62        // Use current working directory if path is relative
63        cwd.join(&file_path)
64    };
65
66    // Confine to the workspace and resolve symlinks BEFORE touching the file —
67    // the same guarantee ReadFiles/ContextSave give. Without this, ReadImage is
68    // an arbitrary-file read primitive (`/etc/shadow`, `~/.ssh/*`), and over the
69    // network-reachable HTTP transport that means remote exfiltration.
70    let path = validate_path_in_workspace(&path, workspace_root)
71        .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
72
73    // Ensure it's a regular file (also rejects a path that doesn't exist).
74    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    // Cap the size before reading it into memory + base64-encoding it.
82    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    // Read the file as bytes
91    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    // Encode the bytes to base64
97    let image_b64 = general_purpose::STANDARD.encode(&image_bytes);
98
99    // Guess the MIME type from the file extension
100    let mime_type =
101        MimeGuess::from_path(&path).first_raw().unwrap_or("application/octet-stream").to_string();
102
103    // Verify the MIME type is a supported image type
104    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        // Fall back to a best effort based on common extensions
109        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", // Default fallback
116        };
117
118        debug!("Using fallback MIME type: {}", mime_type);
119        Ok((mime_type.to_string(), image_b64))
120    }
121}
122
123/// Handle the `ReadImage` tool call
124///
125/// This function processes the `ReadImage` tool call, which reads an image file
126/// and returns its contents as base64-encoded data with the appropriate MIME type.
127///
128/// # Arguments
129///
130/// * `bash_state_arc` - Shared reference to the bash state
131/// * `read_image` - The read image parameters
132///
133/// # Returns
134///
135/// A Result containing a tuple with the MIME type and base64-encoded image data
136///
137/// # Errors
138///
139/// Returns an error if the image file cannot be accessed or read
140#[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    // We need to extract data from the bash state before awaiting
148    // to avoid holding the MutexGuard across await points
149    let cwd: PathBuf;
150    let workspace_root: PathBuf;
151
152    // Lock bash state to extract data
153    {
154        let bash_state_guard = bash_state_arc.lock().await;
155
156        // Ensure bash state is initialized
157        let Some(bash_state) = &*bash_state_guard else {
158            error!("BashState not initialized");
159            return Err(WinxError::BashStateNotInitialized);
160        };
161
162        // Extract needed data
163        cwd = bash_state.cwd.clone();
164        workspace_root = bash_state.workspace_root.clone();
165    }
166
167    // Read the image file
168    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        // The exfil case: an absolute path outside the workspace must be refused,
192        // not read (this is what made ReadImage an arbitrary-file-read primitive).
193        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}