Skip to main content

zeroclaw_tools/
image_info.rs

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
9/// Maximum file size we will read and base64-encode (5 MB).
10const MAX_IMAGE_BYTES: u64 = 5_242_880;
11
12/// Tool to read image metadata and optionally return base64-encoded data.
13///
14/// Since model_providers are currently text-only, this tool extracts what it can
15/// (file size, format, dimensions from header bytes) and provides base64
16/// data for future multimodal model_provider support.
17pub struct ImageInfoTool {
18    // Held for API symmetry with other tools and to keep room for future
19    // tool-specific checks (e.g. post-canonicalization is_resolved_path_allowed).
20    // Pre-canonicalization path-allowlist enforcement now lives in the
21    // PathGuardedTool wrapper applied at registration time.
22    #[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    /// Detect image format from first few bytes (magic numbers).
32    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    /// Try to extract dimensions from image header bytes.
52    /// Returns (width, height) if detectable.
53    fn extract_dimensions(bytes: &[u8], format: &str) -> Option<(u32, u32)> {
54        match format {
55            "png" => {
56                // PNG IHDR chunk: bytes 16-19 = width, 20-23 = height (big-endian)
57                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                // GIF: bytes 6-7 = width, 8-9 = height (little-endian)
67                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                // BMP: bytes 18-21 = width, 22-25 = height (little-endian, signed)
77                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    /// Parse JPEG SOF markers to extract dimensions.
92    fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
93        let mut i = 2; // skip SOI marker
94        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            // SOF0..SOF3 markers contain dimensions
102            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            // Skip this segment
112            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; // Malformed segment (valid segments have length >= 2)
116                }
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        // Path-allowlist checks are applied by the PathGuardedTool wrapper at
173        // registration time (see zeroclaw-runtime::tools::mod). Rate limiting
174        // for this tool is also wrapper-driven via RateLimitedTool.
175
176        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    /// Security policy with `workspace_only: true` so external absolute paths
273    /// are blocked by the `PathGuardedTool` wrapper.
274    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    /// Wraps `ImageInfoTool` with the production `PathGuardedTool` +
284    /// `RateLimitedTool` stack, mirroring the registration in
285    /// `zeroclaw-runtime::tools::mod`.  Use this in tests that exercise
286    /// path-allowlist or rate-limit behavior.
287    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    // ── Format detection ────────────────────────────────────────
327
328    #[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    // ── Dimension extraction ────────────────────────────────────
371
372    #[test]
373    fn png_dimensions() {
374        // Minimal PNG IHDR: 8-byte signature + 4-byte length + 4-byte IHDR + 4-byte width + 4-byte height
375        let mut bytes = vec![
376            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
377            0x00, 0x00, 0x00, 0x0D, // IHDR length
378            0x49, 0x48, 0x44, 0x52, // "IHDR"
379            0x00, 0x00, 0x03, 0x20, // width: 800
380            0x00, 0x00, 0x02, 0x58, // height: 600
381        ];
382        bytes.extend_from_slice(&[0u8; 10]); // padding
383        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, // GIF89a
391            0x40, 0x01, // width: 320 (LE)
392            0xF0, 0x00, // height: 240 (LE)
393        ];
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        // width at offset 18 (LE): 1024
404        bytes[18] = 0x00;
405        bytes[19] = 0x04;
406        bytes[20] = 0x00;
407        bytes[21] = 0x00;
408        // height at offset 22 (LE): 768
409        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        // Minimal JPEG-like byte sequence with SOF0 marker
420        let mut bytes: Vec<u8> = vec![
421            0xFF, 0xD8, // SOI
422            0xFF, 0xE0, // APP0 marker
423            0x00, 0x10, // APP0 length = 16
424        ];
425        bytes.extend_from_slice(&[0u8; 14]); // APP0 payload
426        bytes.extend_from_slice(&[
427            0xFF, 0xC0, // SOF0 marker
428            0x00, 0x11, // SOF0 length
429            0x08, // precision
430            0x01, 0xE0, // height: 480
431            0x02, 0x80, // width: 640
432        ]);
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        // Zero-length segment should return None instead of looping forever
440        let bytes: Vec<u8> = vec![
441            0xFF, 0xD8, // SOI
442            0xFF, 0xE0, // APP0 marker
443            0x00, 0x00, // length = 0 (malformed)
444        ];
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    // ── Execute tests ───────────────────────────────────────────
457
458    #[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        // Create a minimal valid PNG
479        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        // Minimal 1x1 red PNG (67 bytes)
484        let png_bytes: Vec<u8> = vec![
485            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature
486            0x00, 0x00, 0x00, 0x0D, // IHDR length
487            0x49, 0x48, 0x44, 0x52, // IHDR
488            0x00, 0x00, 0x00, 0x01, // width: 1
489            0x00, 0x00, 0x00, 0x01, // height: 1
490            0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc.
491            0x90, 0x77, 0x53, 0xDE, // CRC
492            0x00, 0x00, 0x00, 0x0C, // IDAT length
493            0x49, 0x44, 0x41, 0x54, // IDAT
494            0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21,
495            0xBC, 0x33, // CRC
496            0x00, 0x00, 0x00, 0x00, // IEND length
497            0x49, 0x45, 0x4E, 0x44, // IEND
498            0xAE, 0x42, 0x60, 0x82, // CRC
499        ];
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        // Clean up
513        let _ = tokio::fs::remove_dir_all(&dir).await;
514    }
515
516    #[tokio::test]
517    async fn wrapped_blocks_external_absolute_path() {
518        // Regression for the removed inline path check: when ImageInfoTool is
519        // composed with PathGuardedTool (as it is in production), an external
520        // absolute path must be blocked before the inner tool runs.
521        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        // Path-traversal under workspace_only must be blocked by the wrapper,
549        // not pass through to the inner tool.
550        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        // Minimal 1x1 PNG
577        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}