1use async_trait::async_trait;
2use serde_json::json;
3use std::fmt::Write;
4use std::path::Path;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8
9const MAX_IMAGE_BYTES: u64 = 5_242_880;
11
12pub struct ImageInfoTool {
18 #[allow(dead_code)]
23 security: Arc<SecurityPolicy>,
24}
25
26impl ImageInfoTool {
27 pub fn new(security: Arc<SecurityPolicy>) -> Self {
28 Self { security }
29 }
30
31 fn detect_format(bytes: &[u8]) -> &'static str {
33 if bytes.len() < 4 {
34 return "unknown";
35 }
36 if bytes.starts_with(b"\x89PNG") {
37 "png"
38 } else if bytes.starts_with(b"\xFF\xD8\xFF") {
39 "jpeg"
40 } else if bytes.starts_with(b"GIF8") {
41 "gif"
42 } else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
43 "webp"
44 } else if bytes.starts_with(b"BM") {
45 "bmp"
46 } else {
47 "unknown"
48 }
49 }
50
51 fn extract_dimensions(bytes: &[u8], format: &str) -> Option<(u32, u32)> {
54 match format {
55 "png" => {
56 if bytes.len() >= 24 {
58 let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
59 let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
60 Some((w, h))
61 } else {
62 None
63 }
64 }
65 "gif" => {
66 if bytes.len() >= 10 {
68 let w = u32::from(u16::from_le_bytes([bytes[6], bytes[7]]));
69 let h = u32::from(u16::from_le_bytes([bytes[8], bytes[9]]));
70 Some((w, h))
71 } else {
72 None
73 }
74 }
75 "bmp" => {
76 if bytes.len() >= 26 {
78 let w = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
79 let h_raw = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
80 let h = h_raw.unsigned_abs();
81 Some((w, h))
82 } else {
83 None
84 }
85 }
86 "jpeg" => Self::jpeg_dimensions(bytes),
87 _ => None,
88 }
89 }
90
91 fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
93 let mut i = 2; while i + 1 < bytes.len() {
95 if bytes[i] != 0xFF {
96 return None;
97 }
98 let marker = bytes[i + 1];
99 i += 2;
100
101 if (0xC0..=0xC3).contains(&marker) {
103 if i + 7 <= bytes.len() {
104 let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]]));
105 let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]));
106 return Some((w, h));
107 }
108 return None;
109 }
110
111 if i + 1 < bytes.len() {
113 let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
114 if seg_len < 2 {
115 return None; }
117 i += seg_len;
118 } else {
119 return None;
120 }
121 }
122 None
123 }
124}
125
126#[async_trait]
127impl Tool for ImageInfoTool {
128 fn name(&self) -> &str {
129 "image_info"
130 }
131
132 fn description(&self) -> &str {
133 "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data."
134 }
135
136 fn parameters_schema(&self) -> serde_json::Value {
137 json!({
138 "type": "object",
139 "properties": {
140 "path": {
141 "type": "string",
142 "description": "Path to the image file (absolute or relative to workspace)"
143 },
144 "include_base64": {
145 "type": "boolean",
146 "description": "Include base64-encoded image data in output (default: false)"
147 }
148 },
149 "required": ["path"]
150 })
151 }
152
153 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
154 let path_str = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
155 ::zeroclaw_log::record!(
156 WARN,
157 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
158 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
159 .with_attrs(::serde_json::json!({"param": "path"})),
160 "image_info: missing path parameter"
161 );
162 anyhow::Error::msg("Missing 'path' parameter")
163 })?;
164
165 let include_base64 = args
166 .get("include_base64")
167 .and_then(serde_json::Value::as_bool)
168 .unwrap_or(false);
169
170 let path = Path::new(path_str);
171
172 if !path.exists() {
177 return Ok(ToolResult {
178 success: false,
179 output: String::new(),
180 error: Some(format!("File not found: {path_str}")),
181 });
182 }
183
184 let metadata = tokio::fs::metadata(path).await.map_err(|e| {
185 ::zeroclaw_log::record!(
186 ERROR,
187 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
188 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
189 .with_attrs(::serde_json::json!({
190 "path": path_str,
191 "error": format!("{}", e),
192 })),
193 "image_info: failed to read file metadata"
194 );
195 anyhow::Error::msg(format!("Failed to read file metadata: {e}"))
196 })?;
197
198 let file_size = metadata.len();
199
200 if file_size > MAX_IMAGE_BYTES {
201 return Ok(ToolResult {
202 success: false,
203 output: String::new(),
204 error: Some(format!(
205 "Image too large: {file_size} bytes (max {MAX_IMAGE_BYTES} bytes)"
206 )),
207 });
208 }
209
210 let bytes = tokio::fs::read(path).await.map_err(|e| {
211 ::zeroclaw_log::record!(
212 ERROR,
213 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
214 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
215 .with_attrs(::serde_json::json!({
216 "path": path_str,
217 "error": format!("{}", e),
218 })),
219 "image_info: failed to read image file"
220 );
221 anyhow::Error::msg(format!("Failed to read image file: {e}"))
222 })?;
223
224 let format = Self::detect_format(&bytes);
225 let dimensions = Self::extract_dimensions(&bytes, format);
226
227 let mut output = format!("File: {path_str}\nFormat: {format}\nSize: {file_size} bytes");
228
229 if let Some((w, h)) = dimensions {
230 let _ = write!(output, "\nDimensions: {w}x{h}");
231 }
232
233 if include_base64 {
234 use base64::Engine;
235 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
236 let mime = match format {
237 "png" => "image/png",
238 "jpeg" => "image/jpeg",
239 "gif" => "image/gif",
240 "webp" => "image/webp",
241 "bmp" => "image/bmp",
242 _ => "application/octet-stream",
243 };
244 let _ = write!(output, "\ndata:{mime};base64,{encoded}");
245 }
246
247 Ok(ToolResult {
248 success: true,
249 output,
250 error: None,
251 })
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::wrappers::{PathGuardedTool, RateLimitedTool};
259 use zeroclaw_config::autonomy::AutonomyLevel;
260 use zeroclaw_config::policy::SecurityPolicy;
261
262 fn test_security() -> Arc<SecurityPolicy> {
263 Arc::new(SecurityPolicy {
264 autonomy: AutonomyLevel::Full,
265 workspace_dir: std::env::temp_dir(),
266 workspace_only: false,
267 forbidden_paths: vec![],
268 ..SecurityPolicy::default()
269 })
270 }
271
272 fn workspace_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
275 Arc::new(SecurityPolicy {
276 autonomy: AutonomyLevel::Full,
277 workspace_dir: workspace,
278 workspace_only: true,
279 ..SecurityPolicy::default()
280 })
281 }
282
283 fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
288 let security = workspace_security(workspace);
289 Box::new(RateLimitedTool::new(
290 PathGuardedTool::new(ImageInfoTool::new(security.clone()), security.clone()),
291 security,
292 ))
293 }
294
295 #[test]
296 fn image_info_tool_name() {
297 let tool = ImageInfoTool::new(test_security());
298 assert_eq!(tool.name(), "image_info");
299 }
300
301 #[test]
302 fn image_info_tool_description() {
303 let tool = ImageInfoTool::new(test_security());
304 assert!(!tool.description().is_empty());
305 assert!(tool.description().contains("image"));
306 }
307
308 #[test]
309 fn image_info_tool_schema() {
310 let tool = ImageInfoTool::new(test_security());
311 let schema = tool.parameters_schema();
312 assert!(schema["properties"]["path"].is_object());
313 assert!(schema["properties"]["include_base64"].is_object());
314 let required = schema["required"].as_array().unwrap();
315 assert!(required.contains(&json!("path")));
316 }
317
318 #[test]
319 fn image_info_tool_spec() {
320 let tool = ImageInfoTool::new(test_security());
321 let spec = tool.spec();
322 assert_eq!(spec.name, "image_info");
323 assert!(spec.parameters.is_object());
324 }
325
326 #[test]
329 fn detect_png() {
330 let bytes = b"\x89PNG\r\n\x1a\n";
331 assert_eq!(ImageInfoTool::detect_format(bytes), "png");
332 }
333
334 #[test]
335 fn detect_jpeg() {
336 let bytes = b"\xFF\xD8\xFF\xE0";
337 assert_eq!(ImageInfoTool::detect_format(bytes), "jpeg");
338 }
339
340 #[test]
341 fn detect_gif() {
342 let bytes = b"GIF89a";
343 assert_eq!(ImageInfoTool::detect_format(bytes), "gif");
344 }
345
346 #[test]
347 fn detect_webp() {
348 let bytes = b"RIFF\x00\x00\x00\x00WEBP";
349 assert_eq!(ImageInfoTool::detect_format(bytes), "webp");
350 }
351
352 #[test]
353 fn detect_bmp() {
354 let bytes = b"BM\x00\x00";
355 assert_eq!(ImageInfoTool::detect_format(bytes), "bmp");
356 }
357
358 #[test]
359 fn detect_unknown_short() {
360 let bytes = b"\x00\x01";
361 assert_eq!(ImageInfoTool::detect_format(bytes), "unknown");
362 }
363
364 #[test]
365 fn detect_unknown_garbage() {
366 let bytes = b"this is not an image";
367 assert_eq!(ImageInfoTool::detect_format(bytes), "unknown");
368 }
369
370 #[test]
373 fn png_dimensions() {
374 let mut bytes = vec![
376 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x03, 0x20, 0x00, 0x00, 0x02, 0x58, ];
382 bytes.extend_from_slice(&[0u8; 10]); let dims = ImageInfoTool::extract_dimensions(&bytes, "png");
384 assert_eq!(dims, Some((800, 600)));
385 }
386
387 #[test]
388 fn gif_dimensions() {
389 let bytes = [
390 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x40, 0x01, 0xF0, 0x00, ];
394 let dims = ImageInfoTool::extract_dimensions(&bytes, "gif");
395 assert_eq!(dims, Some((320, 240)));
396 }
397
398 #[test]
399 fn bmp_dimensions() {
400 let mut bytes = vec![0u8; 26];
401 bytes[0] = b'B';
402 bytes[1] = b'M';
403 bytes[18] = 0x00;
405 bytes[19] = 0x04;
406 bytes[20] = 0x00;
407 bytes[21] = 0x00;
408 bytes[22] = 0x00;
410 bytes[23] = 0x03;
411 bytes[24] = 0x00;
412 bytes[25] = 0x00;
413 let dims = ImageInfoTool::extract_dimensions(&bytes, "bmp");
414 assert_eq!(dims, Some((1024, 768)));
415 }
416
417 #[test]
418 fn jpeg_dimensions() {
419 let mut bytes: Vec<u8> = vec![
421 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, ];
425 bytes.extend_from_slice(&[0u8; 14]); bytes.extend_from_slice(&[
427 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x01, 0xE0, 0x02, 0x80, ]);
433 let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg");
434 assert_eq!(dims, Some((640, 480)));
435 }
436
437 #[test]
438 fn jpeg_malformed_zero_length_segment() {
439 let bytes: Vec<u8> = vec![
441 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x00, ];
445 let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg");
446 assert!(dims.is_none());
447 }
448
449 #[test]
450 fn unknown_format_no_dimensions() {
451 let bytes = b"random data here";
452 let dims = ImageInfoTool::extract_dimensions(bytes, "unknown");
453 assert!(dims.is_none());
454 }
455
456 #[tokio::test]
459 async fn execute_missing_path() {
460 let tool = ImageInfoTool::new(test_security());
461 let result = tool.execute(json!({})).await;
462 assert!(result.is_err());
463 }
464
465 #[tokio::test]
466 async fn execute_nonexistent_file() {
467 let tool = ImageInfoTool::new(test_security());
468 let result = tool
469 .execute(json!({"path": "/tmp/nonexistent_image_xyz.png"}))
470 .await
471 .unwrap();
472 assert!(!result.success);
473 assert!(result.error.as_ref().unwrap().contains("not found"));
474 }
475
476 #[tokio::test]
477 async fn execute_real_file() {
478 let dir = std::env::temp_dir().join("zeroclaw_image_info_test");
480 let _ = tokio::fs::create_dir_all(&dir).await;
481 let png_path = dir.join("test.png");
482
483 let png_bytes: Vec<u8> = vec![
485 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21,
495 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, ];
500 tokio::fs::write(&png_path, &png_bytes).await.unwrap();
501
502 let tool = ImageInfoTool::new(test_security());
503 let result = tool
504 .execute(json!({"path": png_path.to_string_lossy()}))
505 .await
506 .unwrap();
507 assert!(result.success);
508 assert!(result.output.contains("Format: png"));
509 assert!(result.output.contains("Dimensions: 1x1"));
510 assert!(!result.output.contains("data:"));
511
512 let _ = tokio::fs::remove_dir_all(&dir).await;
514 }
515
516 #[tokio::test]
517 async fn wrapped_blocks_external_absolute_path() {
518 let workspace = std::env::temp_dir().join("zeroclaw_image_info_wrap");
522 let _ = std::fs::create_dir_all(&workspace);
523 let tool = wrapped_tool(workspace);
524
525 #[cfg(unix)]
526 let target = "/etc/passwd";
527 #[cfg(windows)]
528 let target = {
529 let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
530 format!(r"{sysroot}\System32\drivers\etc\hosts")
531 };
532
533 let result = tool.execute(json!({"path": target})).await.unwrap();
534 assert!(!result.success, "external path must be blocked");
535 assert!(
536 result
537 .error
538 .as_deref()
539 .unwrap_or("")
540 .contains("Path blocked"),
541 "expected 'Path blocked' error, got: {:?}",
542 result.error
543 );
544 }
545
546 #[tokio::test]
547 async fn wrapped_blocks_path_traversal() {
548 let workspace = std::env::temp_dir().join("zeroclaw_image_info_trav");
551 let _ = std::fs::create_dir_all(&workspace);
552 let tool = wrapped_tool(workspace);
553
554 let result = tool
555 .execute(json!({"path": "../../../etc/passwd"}))
556 .await
557 .unwrap();
558 assert!(!result.success, "path traversal must be blocked");
559 assert!(
560 result
561 .error
562 .as_deref()
563 .unwrap_or("")
564 .contains("Path blocked"),
565 "expected 'Path blocked' error, got: {:?}",
566 result.error
567 );
568 }
569
570 #[tokio::test]
571 async fn execute_with_base64() {
572 let dir = std::env::temp_dir().join("zeroclaw_image_info_b64");
573 let _ = tokio::fs::create_dir_all(&dir).await;
574 let png_path = dir.join("test_b64.png");
575
576 let png_bytes: Vec<u8> = vec![
578 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
579 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
580 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
581 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC,
582 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
583 ];
584 tokio::fs::write(&png_path, &png_bytes).await.unwrap();
585
586 let tool = ImageInfoTool::new(test_security());
587 let result = tool
588 .execute(json!({"path": png_path.to_string_lossy(), "include_base64": true}))
589 .await
590 .unwrap();
591 assert!(result.success);
592 assert!(result.output.contains("data:image/png;base64,"));
593
594 let _ = tokio::fs::remove_dir_all(&dir).await;
595 }
596}