agentzero_tools/
image_info.rs1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Deserialize)]
8struct ImageInfoInput {
9 path: String,
10}
11
12#[derive(Debug, Default, Clone, Copy)]
13pub struct ImageInfoTool;
14
15impl ImageInfoTool {
16 fn resolve_path(input_path: &str, workspace_root: &str) -> anyhow::Result<PathBuf> {
17 if input_path.trim().is_empty() {
18 return Err(anyhow!("path is required"));
19 }
20 let relative = Path::new(input_path);
21 if relative.is_absolute() {
22 return Err(anyhow!("absolute paths are not allowed"));
23 }
24 if relative
25 .components()
26 .any(|c| matches!(c, Component::ParentDir))
27 {
28 return Err(anyhow!("path traversal is not allowed"));
29 }
30 let joined = Path::new(workspace_root).join(relative);
31 let canonical_root = Path::new(workspace_root)
32 .canonicalize()
33 .context("unable to resolve workspace root")?;
34 let canonical = joined
35 .canonicalize()
36 .with_context(|| format!("file not found: {input_path}"))?;
37 if !canonical.starts_with(&canonical_root) {
38 return Err(anyhow!("path is outside workspace"));
39 }
40 Ok(canonical)
41 }
42
43 fn detect_format(header: &[u8]) -> &'static str {
44 if header.starts_with(b"\x89PNG") {
45 "PNG"
46 } else if header.starts_with(b"\xFF\xD8\xFF") {
47 "JPEG"
48 } else if header.starts_with(b"GIF8") {
49 "GIF"
50 } else if header.starts_with(b"RIFF") && header.len() >= 12 && &header[8..12] == b"WEBP" {
51 "WebP"
52 } else if header.starts_with(b"BM") {
53 "BMP"
54 } else {
55 "unknown"
56 }
57 }
58
59 fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
60 if data.len() < 24 {
61 return None;
62 }
63 let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
64 let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
65 Some((width, height))
66 }
67
68 fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
69 let mut i = 2;
70 while i + 9 < data.len() {
71 if data[i] != 0xFF {
72 return None;
73 }
74 let marker = data[i + 1];
75 if marker == 0xC0 || marker == 0xC2 {
76 let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
77 let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
78 return Some((width, height));
79 }
80 let seg_len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
81 i += 2 + seg_len;
82 }
83 None
84 }
85
86 fn gif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
87 if data.len() < 10 {
88 return None;
89 }
90 let width = u16::from_le_bytes([data[6], data[7]]) as u32;
91 let height = u16::from_le_bytes([data[8], data[9]]) as u32;
92 Some((width, height))
93 }
94}
95
96#[async_trait]
97impl Tool for ImageInfoTool {
98 fn name(&self) -> &'static str {
99 "image_info"
100 }
101
102 fn description(&self) -> &'static str {
103 "Get metadata about an image file: format, dimensions, and file size."
104 }
105
106 fn input_schema(&self) -> Option<serde_json::Value> {
107 Some(serde_json::json!({
108 "type": "object",
109 "properties": {
110 "path": { "type": "string", "description": "Path to the image file" }
111 },
112 "required": ["path"]
113 }))
114 }
115
116 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
117 let req: ImageInfoInput =
118 serde_json::from_str(input).context("image_info expects JSON: {\"path\": \"...\"}")?;
119
120 let file_path = Self::resolve_path(&req.path, &ctx.workspace_root)?;
121 let data = tokio::fs::read(&file_path)
122 .await
123 .with_context(|| format!("failed to read file: {}", req.path))?;
124
125 let format = Self::detect_format(&data);
126 let dimensions = match format {
127 "PNG" => Self::png_dimensions(&data),
128 "JPEG" => Self::jpeg_dimensions(&data),
129 "GIF" => Self::gif_dimensions(&data),
130 _ => None,
131 };
132
133 let file_size = data.len();
134 let mut output = format!("format={format}\nsize={file_size} bytes");
135 if let Some((w, h)) = dimensions {
136 output.push_str(&format!("\nwidth={w}\nheight={h}"));
137 }
138
139 Ok(ToolResult { output })
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::fs;
147 use std::sync::atomic::{AtomicU64, Ordering};
148 use std::time::{SystemTime, UNIX_EPOCH};
149
150 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
151
152 fn temp_dir() -> PathBuf {
153 let nanos = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .expect("clock")
156 .as_nanos();
157 let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
158 let dir = std::env::temp_dir().join(format!(
159 "agentzero-image-info-{}-{nanos}-{seq}",
160 std::process::id()
161 ));
162 fs::create_dir_all(&dir).expect("temp dir should be created");
163 dir
164 }
165
166 #[tokio::test]
167 async fn image_info_detects_png() {
168 let dir = temp_dir();
169 let mut png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
171 png.extend_from_slice(&[0x00, 0x00, 0x00, 0x0D]);
173 png.extend_from_slice(b"IHDR");
175 png.extend_from_slice(&100u32.to_be_bytes());
177 png.extend_from_slice(&50u32.to_be_bytes());
178 png.extend_from_slice(&[8, 2, 0, 0, 0]);
180 fs::write(dir.join("test.png"), &png).unwrap();
181
182 let tool = ImageInfoTool;
183 let result = tool
184 .execute(
185 r#"{"path": "test.png"}"#,
186 &ToolContext::new(dir.to_string_lossy().to_string()),
187 )
188 .await
189 .expect("should succeed");
190 assert!(result.output.contains("format=PNG"));
191 assert!(result.output.contains("width=100"));
192 assert!(result.output.contains("height=50"));
193 fs::remove_dir_all(dir).ok();
194 }
195
196 #[tokio::test]
197 async fn image_info_rejects_path_traversal() {
198 let dir = temp_dir();
199 let tool = ImageInfoTool;
200 let err = tool
201 .execute(
202 r#"{"path": "../escape.png"}"#,
203 &ToolContext::new(dir.to_string_lossy().to_string()),
204 )
205 .await
206 .expect_err("path traversal should fail");
207 assert!(err.to_string().contains("path traversal"));
208 fs::remove_dir_all(dir).ok();
209 }
210
211 #[tokio::test]
212 async fn image_info_non_image_file() {
213 let dir = temp_dir();
214 fs::write(dir.join("test.txt"), "hello world").unwrap();
215 let tool = ImageInfoTool;
216 let result = tool
217 .execute(
218 r#"{"path": "test.txt"}"#,
219 &ToolContext::new(dir.to_string_lossy().to_string()),
220 )
221 .await
222 .expect("should succeed even for non-image");
223 assert!(result.output.contains("format=unknown"));
224 fs::remove_dir_all(dir).ok();
225 }
226
227 #[test]
228 fn detect_format_from_headers() {
229 assert_eq!(ImageInfoTool::detect_format(b"\x89PNG\r\n\x1a\n"), "PNG");
230 assert_eq!(ImageInfoTool::detect_format(b"\xFF\xD8\xFF\xE0"), "JPEG");
231 assert_eq!(ImageInfoTool::detect_format(b"GIF89a"), "GIF");
232 assert_eq!(ImageInfoTool::detect_format(b"hello"), "unknown");
233 }
234}