1use super::{expand_path, Tool, ToolContext, ToolOutput};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4
5pub(crate) const MAX_IMAGE_BYTES: usize = 3_670_016;
7const MAX_IMAGE_SIDE_PX: u32 = 8000;
9const PROVIDER_DOWNSCALE_LONG_EDGE: u32 = 1568;
11pub(crate) const MAX_READ_BYTES: u64 = 64 * 1024 * 1024;
15
16pub struct ReadTool;
17
18#[async_trait::async_trait]
19impl Tool for ReadTool {
20 fn effect(&self) -> crate::tools::catalog::ToolEffect {
21 crate::tools::catalog::ToolEffect::ReadOnly
22 }
23
24 fn origin(&self) -> crate::tools::ToolOrigin {
25 crate::tools::ToolOrigin::Builtin
26 }
27
28 fn name(&self) -> &str {
29 "read"
30 }
31
32 fn description(&self) -> &str {
33 "Read the contents of a file. Returns lines with line numbers. Reads up to 500 lines by default. For large files, use offset and limit to read in sections. Image files (PNG, JPEG, GIF, WebP) are returned as an image the model can see, up to 3.5 MB."
34 }
35
36 fn parameters(&self) -> Value {
37 json!({
38 "type": "object",
39 "properties": {
40 "path": {
41 "type": "string",
42 "description": "Path to the file to read"
43 },
44 "offset": {
45 "type": "integer",
46 "description": "Line number to start reading from (0-indexed, default: 0)"
47 },
48 "limit": {
49 "type": "integer",
50 "description": "Maximum number of lines to read (default: all lines)"
51 }
52 },
53 "required": ["path"]
54 })
55 }
56
57 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
58 self.execute_rich(params, ctx)
59 .await
60 .map(ToolOutput::into_summary)
61 }
62
63 async fn execute_rich(&self, params: Value, _ctx: ToolContext) -> Result<ToolOutput> {
64 let raw_path = params["path"]
65 .as_str()
66 .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
67 let path = expand_path(raw_path);
68
69 let meta = tokio::fs::metadata(&path).await.map_err(|e| {
71 RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
72 })?;
73 if !meta.is_file() {
74 return Err(RuntimeError::Tool(format!(
75 "'{}' is not a regular file. Use `bash` (ls, file, head) to inspect it.",
76 path.display()
77 )));
78 }
79 if meta.len() > MAX_READ_BYTES {
80 return Err(RuntimeError::Tool(format!(
81 "File '{}' is {} KB; the read tool refuses files over {} KB. Use `bash` with `head`, `tail`, `xxd`, or `sed -n` to read a slice, or shrink an image with `convert`.",
82 path.display(),
83 meta.len().div_ceil(1024),
84 MAX_READ_BYTES / 1024
85 )));
86 }
87
88 let bytes = tokio::fs::read(&path).await.map_err(|e| {
90 RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
91 })?;
92
93 if let Some(mime) = sniff_image_mime(&bytes) {
94 return image_output(&path, mime, &bytes);
95 }
96
97 let content = match String::from_utf8(bytes) {
98 Ok(s) => s,
99 Err(_) => return Err(RuntimeError::Tool(format!(
100 "File '{}' appears to be binary (not valid UTF-8). Use `bash` with `xxd` or `file` to inspect binary files. Image files (png/jpg/gif/webp) are returned as images automatically.",
101 path.display()
102 ))),
103 };
104
105 let lines: Vec<&str> = content.lines().collect();
106 let total_lines = lines.len();
107
108 let offset = params["offset"].as_u64().unwrap_or(0) as usize;
109 let limit = params["limit"]
110 .as_u64()
111 .map(|l| l as usize)
112 .unwrap_or(500.min(total_lines));
113
114 let start = offset.min(total_lines);
115 let end = (start + limit).min(total_lines);
116
117 let mut result = String::new();
118 for (i, line) in lines[start..end].iter().enumerate() {
119 result.push_str(&format!("{}\t{}\n", start + i + 1, line));
120 }
121
122 if total_lines > end {
123 result.push_str(&format!("\n... ({} more lines)", total_lines - end));
124 }
125
126 Ok(ToolOutput::Text(result))
127 }
128}
129
130pub(crate) fn sniff_image_mime(b: &[u8]) -> Option<&'static str> {
132 if b.len() >= 8 && b[..8] == [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
133 return Some("image/png");
134 }
135 if b.len() >= 3 && b[..3] == [0xFF, 0xD8, 0xFF] {
136 return Some("image/jpeg");
137 }
138 if b.len() >= 6 && (&b[..6] == b"GIF87a" || &b[..6] == b"GIF89a") {
139 return Some("image/gif");
140 }
141 if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" {
142 return Some("image/webp");
143 }
144 None
145}
146
147pub(crate) fn image_dimensions(mime: &str, b: &[u8]) -> Option<(u32, u32)> {
150 match mime {
151 "image/png" if b.len() >= 24 => Some((be32(&b[16..20]), be32(&b[20..24]))),
152 "image/gif" if b.len() >= 10 => Some((le16(&b[6..8]) as u32, le16(&b[8..10]) as u32)),
153 "image/webp" if b.len() >= 30 => match &b[12..16] {
154 b"VP8 " => Some((
155 (le16(&b[26..28]) & 0x3FFF) as u32,
156 (le16(&b[28..30]) & 0x3FFF) as u32,
157 )),
158 b"VP8L" => {
159 let x = [b[21], b[22], b[23], b[24]];
160 Some((
161 1 + (((x[1] & 0x3F) as u32) << 8 | x[0] as u32),
162 1 + (((x[3] & 0x0F) as u32) << 10
163 | (x[2] as u32) << 2
164 | ((x[1] & 0xC0) as u32) >> 6),
165 ))
166 }
167 b"VP8X" => Some((1 + le24(&b[24..27]), 1 + le24(&b[27..30]))),
168 _ => None,
169 },
170 "image/jpeg" => jpeg_dimensions(b),
171 _ => None,
172 }
173}
174
175fn jpeg_dimensions(b: &[u8]) -> Option<(u32, u32)> {
176 let mut i = 2usize;
178 while i + 4 <= b.len() {
179 if b[i] != 0xFF {
180 return None;
181 }
182 let m = b[i + 1];
183 if m == 0xFF {
184 i += 1; continue;
186 }
187 if m == 0xD8 || m == 0x01 || (0xD0..=0xD7).contains(&m) {
188 i += 2; continue;
190 }
191 if m == 0xD9 || m == 0xDA {
192 return None; }
194 let len = be16(&b[i + 2..i + 4]) as usize;
195 if matches!(m, 0xC0..=0xCF) && !matches!(m, 0xC4 | 0xC8 | 0xCC) {
196 if i + 9 > b.len() {
197 return None;
198 }
199 return Some((be16(&b[i + 7..i + 9]) as u32, be16(&b[i + 5..i + 7]) as u32));
201 }
202 i += 2 + len;
203 }
204 None
205}
206
207fn be16(b: &[u8]) -> u16 {
208 u16::from_be_bytes([b[0], b[1]])
209}
210fn be32(b: &[u8]) -> u32 {
211 u32::from_be_bytes([b[0], b[1], b[2], b[3]])
212}
213fn le16(b: &[u8]) -> u16 {
214 u16::from_le_bytes([b[0], b[1]])
215}
216fn le24(b: &[u8]) -> u32 {
217 u32::from_le_bytes([b[0], b[1], b[2], 0])
218}
219fn le32(b: &[u8]) -> u32 {
220 u32::from_le_bytes([b[0], b[1], b[2], b[3]])
221}
222
223pub(crate) fn image_integrity_error(mime: &str, b: &[u8]) -> Option<&'static str> {
228 match mime {
229 "image/png" => {
230 if b.len() < 16 || &b[12..16] != b"IHDR" {
231 return Some("first chunk is not IHDR");
232 }
233 if !b.ends_with(b"IEND\xAE\x42\x60\x82") {
234 return Some("missing IEND trailer (truncated?)");
235 }
236 }
237 "image/jpeg" if !b.ends_with(&[0xFF, 0xD9]) => {
238 return Some("missing EOI marker (truncated?)");
239 }
240 "image/gif" if !b.ends_with(&[0x3B]) => {
241 return Some("missing GIF trailer (truncated?)");
242 }
243 "image/webp" => {
244 let riff = le32(&b[4..8]) as usize;
245 if riff + 8 != b.len() && riff + 9 != b.len() {
247 return Some("RIFF size does not match file length (truncated?)");
248 }
249 }
250 _ => {}
251 }
252 None
253}
254
255fn image_output(path: &std::path::Path, mime: &'static str, bytes: &[u8]) -> Result<ToolOutput> {
256 use base64::Engine as _;
257 let kb = bytes.len().div_ceil(1024);
258 if bytes.len() > MAX_IMAGE_BYTES {
259 return Err(RuntimeError::Tool(format!(
260 "Image '{p}' is {kb} KB; the read tool accepts images up to {cap} KB. \
261 Shrink it with bash and read the new file, e.g.:\n \
262 convert '{p}' -resize 1568x1568\\> -quality 85 /tmp/shrunk.jpg (ImageMagick)\n \
263 sips -Z 1568 '{p}' --out /tmp/shrunk.png (macOS)",
264 p = path.display(),
265 kb = kb,
266 cap = MAX_IMAGE_BYTES / 1024
267 )));
268 }
269 if let Some(why) = image_integrity_error(mime, bytes) {
270 return Err(RuntimeError::Tool(format!(
271 "Image '{}' ({mime}, {kb} KB) appears truncated or corrupt: {why}. \
272 Not sent to the model. Verify with `file` / `identify`, or re-export it and read the new file.",
273 path.display()
274 )));
275 }
276 let Some((w, h)) = image_dimensions(mime, bytes) else {
279 return Err(RuntimeError::Tool(format!(
280 "Image '{}' ({mime}, {kb} KB): could not parse dimensions from the header. \
281 Not sent to the model. Re-export it (e.g. `convert '{}' /tmp/fixed.png`) and read the new file.",
282 path.display(),
283 path.display()
284 )));
285 };
286 if w == 0 || h == 0 {
289 return Err(RuntimeError::Tool(format!(
290 "Image '{}' ({mime}, {kb} KB) declares invalid dimensions {w}x{h} (zero-sized). Not sent to the model. Re-export it and read the new file.",
291 path.display()
292 )));
293 }
294 if w > MAX_IMAGE_SIDE_PX || h > MAX_IMAGE_SIDE_PX {
295 return Err(RuntimeError::Tool(format!(
296 "Image '{}' is {w}x{h}; the provider rejects images with any side over {MAX_IMAGE_SIDE_PX} px. \
297 Downscale it with bash (e.g. `convert ... -resize 1568x1568\\>`) and read the new file.",
298 path.display()
299 )));
300 }
301 let mut summary = format!("Image: {} ({w}x{h}, {mime}, {kb} KB)", path.display());
302 if w.max(h) > PROVIDER_DOWNSCALE_LONG_EDGE {
303 summary.push_str("\nNote: long edge exceeds 1568 px; the provider downscales before viewing, so pixel coordinates read from the image are approximate.");
304 }
305 let data = base64::engine::general_purpose::STANDARD.encode(bytes);
306 Ok(ToolOutput::Blocks {
307 blocks: vec![
308 json!({ "type": "text", "text": summary }),
310 json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": data } }),
311 ],
312 summary,
313 })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::super::test_helpers::create_tool_context;
319 use super::*;
320 use crate::tools::Tool;
321 use serde_json::json;
322
323 #[test]
324 fn test_read_tool_schema() {
325 let tool = ReadTool;
326 assert_eq!(tool.name(), "read");
327 assert!(!tool.description().is_empty());
328
329 let params = tool.parameters();
330 assert_eq!(params["type"], "object");
331 assert!(params["properties"].is_object());
332 assert!(params["required"].is_array());
333 }
334
335 #[tokio::test]
336 async fn test_read_tool_execution() {
337 let temp_dir = std::env::temp_dir();
338 let test_file = temp_dir.join("read_tool_test.txt");
339
340 let content = "line 1\nline 2\nline 3\nline 4\nline 5";
342 std::fs::write(&test_file, content).unwrap();
343
344 let tool = ReadTool;
345 let ctx = create_tool_context();
346
347 let params = json!({
349 "path": test_file.to_string_lossy()
350 });
351 let result = tool.execute(params, ctx).await.unwrap();
352
353 assert!(result.contains("1\tline 1"));
355 assert!(result.contains("2\tline 2"));
356 assert!(result.contains("5\tline 5"));
357
358 let ctx = create_tool_context();
360 let params = json!({
361 "path": test_file.to_string_lossy(),
362 "offset": 2,
363 "limit": 2
364 });
365 let result = tool.execute(params, ctx).await.unwrap();
366
367 assert!(result.contains("3\tline 3"));
368 assert!(result.contains("4\tline 4"));
369 assert!(!result.contains("1\tline 1"));
370 assert!(!result.contains("5\tline 5"));
371
372 let _ = std::fs::remove_file(&test_file);
374 }
375
376 #[tokio::test]
377 async fn test_read_tool_offset() {
378 let temp_dir = std::env::temp_dir();
379 let test_file = temp_dir.join("test_read_tool_offset.txt");
380
381 let lines = (1..=10).map(|i| format!("line {}", i)).collect::<Vec<_>>();
383 let content = lines.join("\n");
384 std::fs::write(&test_file, &content).unwrap();
385
386 let tool = ReadTool;
387 let ctx = create_tool_context();
388
389 let params = json!({
391 "path": test_file.to_string_lossy(),
392 "offset": 5
393 });
394
395 let result = tool.execute(params, ctx).await.unwrap();
396
397 assert!(result.contains("6\tline 6"));
399 assert!(!result.contains("1\tline 1"));
401 assert!(!result.contains("5\tline 5"));
402
403 let _ = std::fs::remove_file(&test_file);
405 }
406
407 const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
410
411 fn png_header(w: u32, h: u32) -> Vec<u8> {
413 let mut v = PNG_SIG.to_vec();
414 v.extend_from_slice(&13u32.to_be_bytes()); v.extend_from_slice(b"IHDR");
416 v.extend_from_slice(&w.to_be_bytes());
417 v.extend_from_slice(&h.to_be_bytes());
418 v
419 }
420
421 const PNG_IEND: [u8; 12] = [0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82];
422
423 fn png_with_dims(w: u32, h: u32) -> Vec<u8> {
425 let mut v = png_header(w, h);
426 v.extend_from_slice(&[8, 6, 0, 0, 0]); v.extend_from_slice(&[0, 0, 0, 0]); v.extend_from_slice(&PNG_IEND);
429 v
430 }
431
432 fn tiny_png() -> Vec<u8> {
434 png_with_dims(1, 1)
435 }
436
437 fn tmp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
438 let p = std::env::temp_dir().join(format!("read_img_{}_{name}", std::process::id()));
439 std::fs::write(&p, bytes).unwrap();
440 p
441 }
442
443 #[test]
444 fn sniff_png_jpeg_gif_webp() {
445 assert_eq!(sniff_image_mime(&PNG_SIG), Some("image/png"));
446 assert_eq!(
447 sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
448 Some("image/jpeg")
449 );
450 assert_eq!(
451 sniff_image_mime(b"GIF89a\x01\x00\x01\x00"),
452 Some("image/gif")
453 );
454 assert_eq!(
455 sniff_image_mime(b"GIF87a\x01\x00\x01\x00"),
456 Some("image/gif")
457 );
458 assert_eq!(
459 sniff_image_mime(b"RIFF\x00\x00\x00\x00WEBPVP8 "),
460 Some("image/webp")
461 );
462 assert_eq!(sniff_image_mime(b"hello"), None);
463 assert_eq!(
464 sniff_image_mime(b"BM\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"),
465 None
466 );
467 assert_eq!(sniff_image_mime(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3"), None);
468 assert_eq!(sniff_image_mime(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
469 assert_eq!(sniff_image_mime(b""), None);
470 }
471
472 #[tokio::test]
473 async fn sniff_ignores_extension() {
474 let text_as_png = tmp("notes.png", b"just text\nline two");
475 let out = ReadTool
476 .execute_rich(
477 json!({"path": text_as_png.to_string_lossy()}),
478 create_tool_context(),
479 )
480 .await
481 .unwrap();
482 match out {
483 ToolOutput::Text(s) => assert!(s.contains("1\tjust text"), "{s}"),
484 other => panic!("expected Text, got {other:?}"),
485 }
486
487 let png_as_txt = tmp("data.txt", &tiny_png());
488 let out = ReadTool
489 .execute_rich(
490 json!({"path": png_as_txt.to_string_lossy()}),
491 create_tool_context(),
492 )
493 .await
494 .unwrap();
495 assert!(matches!(out, ToolOutput::Blocks { .. }), "{out:?}");
496 let _ = std::fs::remove_file(text_as_png);
497 let _ = std::fs::remove_file(png_as_txt);
498 }
499
500 #[test]
501 fn png_dimensions_from_ihdr() {
502 assert_eq!(image_dimensions("image/png", &tiny_png()), Some((1, 1)));
503 assert_eq!(
504 image_dimensions("image/png", &png_header(1024, 1536)),
505 Some((1024, 1536))
506 );
507 assert_eq!(image_dimensions("image/png", &PNG_SIG), None);
508 }
509
510 #[test]
511 fn jpeg_dimensions_from_sof0() {
512 let mut j = vec![0xFF, 0xD8]; j.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); j.extend_from_slice(b"JFIF\0");
515 j.extend_from_slice(&[1, 1, 0, 0, 1, 0, 1, 0, 0]); j.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); j.extend_from_slice(&480u16.to_be_bytes()); j.extend_from_slice(&640u16.to_be_bytes()); j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1]);
520 assert_eq!(image_dimensions("image/jpeg", &j), Some((640, 480)));
521
522 let sos_only = [0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0];
524 assert_eq!(image_dimensions("image/jpeg", &sos_only), None);
525
526 let trunc = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x01];
528 assert_eq!(image_dimensions("image/jpeg", &trunc), None);
529
530 let mut seed = 0x9E37_79B9u32;
532 for _ in 0..200 {
533 let mut buf = vec![0xFF, 0xD8, 0xFF];
534 for _ in 0..64 {
535 seed ^= seed << 13;
536 seed ^= seed >> 17;
537 seed ^= seed << 5;
538 buf.push((seed & 0xFF) as u8);
539 }
540 let _ = image_dimensions("image/jpeg", &buf);
541 }
542 }
543
544 #[test]
545 fn webp_dimensions_vp8_vp8l_vp8x() {
546 let mut vp8 = b"RIFF\x00\x00\x00\x00WEBPVP8 \x00\x00\x00\x00".to_vec();
548 vp8.extend_from_slice(&[0x00, 0x00, 0x00, 0x9D, 0x01, 0x2A]);
549 vp8.extend_from_slice(&320u16.to_le_bytes());
550 vp8.extend_from_slice(&240u16.to_le_bytes());
551 assert_eq!(image_dimensions("image/webp", &vp8), Some((320, 240)));
552
553 let (w, h) = (100u32, 50u32);
555 let bits: u32 = (w - 1) | ((h - 1) << 14);
556 let mut vp8l = b"RIFF\x00\x00\x00\x00WEBPVP8L\x00\x00\x00\x00\x2F".to_vec();
557 vp8l.extend_from_slice(&bits.to_le_bytes());
558 vp8l.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
559 assert_eq!(image_dimensions("image/webp", &vp8l), Some((100, 50)));
560
561 let mut vp8x = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0A\x00\x00\x00".to_vec();
563 vp8x.extend_from_slice(&[0, 0, 0, 0]);
564 vp8x.extend_from_slice(&(1999u32).to_le_bytes()[..3]);
565 vp8x.extend_from_slice(&(999u32).to_le_bytes()[..3]);
566 assert_eq!(image_dimensions("image/webp", &vp8x), Some((2000, 1000)));
567 }
568
569 #[tokio::test]
570 async fn image_read_block_ordering() {
571 use base64::Engine as _;
572 let bytes = tiny_png();
573 let p = tmp("order.png", &bytes);
574 let out = ReadTool
575 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
576 .await
577 .unwrap();
578 let ToolOutput::Blocks { blocks, summary } = out else {
579 panic!("expected Blocks");
580 };
581 assert_eq!(blocks.len(), 2);
582 assert_eq!(blocks[0]["type"], "text");
583 assert_eq!(blocks[0]["text"], summary);
584 assert_eq!(blocks[1]["type"], "image");
585 assert_eq!(blocks[1]["source"]["type"], "base64");
586 assert_eq!(blocks[1]["source"]["media_type"], "image/png");
587 let decoded = base64::engine::general_purpose::STANDARD
588 .decode(blocks[1]["source"]["data"].as_str().unwrap())
589 .unwrap();
590 assert_eq!(decoded, bytes);
591 let _ = std::fs::remove_file(p);
592 }
593
594 #[tokio::test]
595 async fn image_over_cap_rejected() {
596 let mut bytes = png_header(10, 10);
597 bytes.resize(MAX_IMAGE_BYTES + 1, 0);
598 let p = tmp("big.png", &bytes);
599 let err = ReadTool
600 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
601 .await
602 .unwrap_err();
603 let RuntimeError::Tool(msg) = err else {
604 panic!("expected Tool error, got {err:?}");
605 };
606 assert!(msg.contains("convert"), "{msg}");
607 assert!(msg.contains("KB"), "{msg}");
608 assert!(msg.len() < 1024, "{}", msg.len());
609 let _ = std::fs::remove_file(p);
610 }
611
612 #[tokio::test]
613 async fn image_over_8000px_rejected() {
614 let p = tmp("wide.png", &png_with_dims(8001, 10));
615 let err = ReadTool
616 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
617 .await
618 .unwrap_err();
619 assert!(err.to_string().contains("8000"), "{err}");
620 let _ = std::fs::remove_file(p);
621 }
622
623 #[tokio::test]
624 async fn legacy_execute_returns_summary_for_images() {
625 let p = tmp("legacy.png", &tiny_png());
626 let s = ReadTool
627 .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
628 .await
629 .unwrap();
630 assert!(s.starts_with("Image: "), "{s}");
631 assert!(s.len() < 512);
632 let _ = std::fs::remove_file(p);
633 }
634
635 #[tokio::test]
636 async fn label_format() {
637 let p = tmp("label.png", &tiny_png());
638 let out = ReadTool
639 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
640 .await
641 .unwrap();
642 assert_eq!(
643 out.summary(),
644 format!("Image: {} (1x1, image/png, 1 KB)", p.display())
645 );
646 let _ = std::fs::remove_file(p);
647
648 let p = tmp("wide2.png", &png_with_dims(2000, 100));
649 let out = ReadTool
650 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
651 .await
652 .unwrap();
653 assert!(out.summary().starts_with(&format!(
654 "Image: {} (2000x100, image/png, 1 KB)",
655 p.display()
656 )));
657 assert!(out.summary().contains("1568 px"), "{}", out.summary());
658 let _ = std::fs::remove_file(p);
659 }
660
661 #[tokio::test]
664 async fn real_avatar_png_round_trips_as_image_blocks() {
665 let Some(home) = std::env::var_os("HOME") else {
666 return;
667 };
668 let p = std::path::PathBuf::from(home).join("Jawz/media/jawz-avatar-v1.png");
669 if !p.exists() {
670 eprintln!(
671 "SKIPPED real_avatar_png_round_trips_as_image_blocks: {} absent",
672 p.display()
673 );
674 return;
675 }
676 let out = ReadTool
677 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
678 .await
679 .unwrap();
680 let ToolOutput::Blocks { blocks, summary } = out else {
681 panic!("expected Blocks");
682 };
683 assert!(
684 summary.starts_with(&format!(
685 "Image: {} (1024x1536, image/png, 2292 KB)",
686 p.display()
687 )),
688 "{summary}"
689 );
690 assert_eq!(blocks[0]["text"], summary);
691 assert_eq!(blocks[1]["source"]["media_type"], "image/png");
692 let data = blocks[1]["source"]["data"].as_str().unwrap();
693 assert!(data.starts_with("iVBORw0KGgo"));
694 assert!(serde_json::to_vec(&blocks[1]).unwrap().len() < 5_000_000);
695 }
696
697 #[tokio::test]
701 async fn real_rs_file_and_optional_big_image() {
702 let out = ReadTool
703 .execute_rich(
704 json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/src/tools/read.rs"), "limit": 3}),
705 create_tool_context(),
706 )
707 .await
708 .unwrap();
709 let ToolOutput::Text(text) = out else {
710 panic!("expected Text")
711 };
712 assert!(text.starts_with("1\tuse super::"), "{text}");
713
714 let Ok(big) = std::env::var("SYNAPS_TEST_BIG_IMAGE") else {
715 eprintln!("SKIPPED big-image half of real_rs_file_and_optional_big_image: SYNAPS_TEST_BIG_IMAGE unset");
716 return;
717 };
718 let err = ReadTool
719 .execute_rich(json!({"path": big}), create_tool_context())
720 .await
721 .unwrap_err()
722 .to_string();
723 assert!(err.contains("accepts images up to 3584 KB"), "{err}");
724 assert!(err.contains("convert"));
725 assert!(err.len() < 1024);
726 }
727
728 #[test]
731 fn integrity_check_per_format() {
732 assert_eq!(image_integrity_error("image/png", &tiny_png()), None);
734 assert!(image_integrity_error("image/png", &png_header(1, 1)).is_some()); let mut bad_chunk = tiny_png();
736 bad_chunk[12..16].copy_from_slice(b"iTXt");
737 assert!(image_integrity_error("image/png", &bad_chunk).is_some());
738 assert!(image_integrity_error("image/png", &PNG_SIG).is_some());
739 assert_eq!(
741 image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xD9]),
742 None
743 );
744 assert!(image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0]).is_some());
745 assert_eq!(
747 image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00\x3B"),
748 None
749 );
750 assert!(image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00").is_some());
751 let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ".to_vec();
753 webp.extend_from_slice(&[0u8; 20]);
754 assert!(image_integrity_error("image/webp", &webp).is_some());
755 let riff = (webp.len() - 8) as u32;
756 webp[4..8].copy_from_slice(&riff.to_le_bytes());
757 assert_eq!(image_integrity_error("image/webp", &webp), None);
758 webp.pop();
759 assert!(image_integrity_error("image/webp", &webp).is_some());
760 }
761
762 #[tokio::test]
763 async fn truncated_png_rejected_no_image_block() {
764 let mut bytes = png_header(10, 10);
766 bytes.resize(3_000, 0);
767 let p = tmp("trunc.png", &bytes);
768 let err = ReadTool
769 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
770 .await
771 .unwrap_err();
772 let RuntimeError::Tool(msg) = err else {
773 panic!("expected Tool error, got {err:?}");
774 };
775 assert!(msg.contains("truncated or corrupt"), "{msg}");
776 assert!(msg.contains("IEND"), "{msg}");
777 assert!(!msg.contains("iVBORw0KGgo"), "no base64 in error");
778 assert!(msg.len() < 1024);
779 let s = ReadTool
781 .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
782 .await
783 .unwrap_err()
784 .to_string();
785 assert!(s.contains("truncated or corrupt"), "{s}");
786 let _ = std::fs::remove_file(p);
787 }
788
789 #[tokio::test]
790 async fn truncated_jpeg_and_gif_rejected() {
791 let jpeg = [
792 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, 1, 1, 0, 0, 1, 0, 1, 0,
793 0,
794 ];
795 let p = tmp("trunc.jpg", &jpeg);
796 let err = ReadTool
797 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
798 .await
799 .unwrap_err()
800 .to_string();
801 assert!(err.contains("EOI"), "{err}");
802 let _ = std::fs::remove_file(p);
803
804 let p = tmp("trunc.gif", b"GIF89a\x01\x00\x01\x00\x00\x00\x00");
805 let err = ReadTool
806 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
807 .await
808 .unwrap_err()
809 .to_string();
810 assert!(err.contains("GIF trailer"), "{err}");
811 let _ = std::fs::remove_file(p);
812 }
813
814 #[tokio::test]
815 async fn unparseable_dims_rejected_not_shipped() {
816 let jpeg = [
819 0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0, 0xFF, 0xD9,
820 ];
821 let p = tmp("nodims.jpg", &jpeg);
822 let err = ReadTool
823 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
824 .await
825 .unwrap_err()
826 .to_string();
827 assert!(err.contains("could not parse dimensions"), "{err}");
828 assert!(err.contains("Not sent to the model"), "{err}");
829 let _ = std::fs::remove_file(p);
830 }
831
832 #[tokio::test]
833 async fn zero_dimension_images_rejected_not_shipped() {
834 for (name, w, h) in [
835 ("w0.png", 0u32, 7u32),
836 ("h0.png", 7, 0),
837 ("both0.png", 0, 0),
838 ] {
839 let p = tmp(name, &png_with_dims(w, h));
840 let err = ReadTool
841 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
842 .await
843 .unwrap_err()
844 .to_string();
845 assert!(err.contains("zero-sized"), "{name}: {err}");
846 assert!(err.contains(&format!("{w}x{h}")), "{name}: {err}");
847 assert!(err.contains("Not sent to the model"), "{name}: {err}");
848 let _ = std::fs::remove_file(p);
849 }
850 }
851
852 #[tokio::test]
853 async fn complete_gif_and_jpeg_round_trip_through_execute_rich() {
854 let p = tmp("ok.gif", b"GIF89a\x02\x00\x03\x00\x00\x00\x00\x3B");
855 let out = ReadTool
856 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
857 .await
858 .unwrap();
859 assert!(
860 out.summary().contains("(2x3, image/gif, 1 KB)"),
861 "{}",
862 out.summary()
863 );
864 assert!(matches!(out, ToolOutput::Blocks { .. }));
865 let _ = std::fs::remove_file(p);
866
867 let mut j = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
868 j.extend_from_slice(&480u16.to_be_bytes());
869 j.extend_from_slice(&640u16.to_be_bytes());
870 j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1, 0xFF, 0xD9]);
871 let p = tmp("ok.jpg", &j);
872 let out = ReadTool
873 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
874 .await
875 .unwrap();
876 assert!(
877 out.summary().contains("(640x480, image/jpeg, 1 KB)"),
878 "{}",
879 out.summary()
880 );
881 let _ = std::fs::remove_file(p);
882 }
883
884 #[tokio::test]
887 async fn oversize_file_rejected_by_metadata_without_reading() {
888 let p = std::env::temp_dir().join(format!("read_sparse_{}.bin", std::process::id()));
891 let f = std::fs::File::create(&p).unwrap();
892 f.set_len(MAX_READ_BYTES + 1).unwrap();
893 drop(f);
894 let start = std::time::Instant::now();
895 let err = ReadTool
896 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
897 .await
898 .unwrap_err()
899 .to_string();
900 assert!(err.contains("refuses files over"), "{err}");
901 assert!(err.contains("head"), "{err}");
902 assert!(start.elapsed() < std::time::Duration::from_secs(2));
903 let _ = std::fs::remove_file(p);
904 }
905
906 #[tokio::test]
907 async fn directory_and_device_rejected_as_not_regular_file() {
908 let err = ReadTool
909 .execute_rich(
910 json!({"path": std::env::temp_dir().to_string_lossy()}),
911 create_tool_context(),
912 )
913 .await
914 .unwrap_err()
915 .to_string();
916 assert!(err.contains("not a regular file"), "{err}");
917
918 #[cfg(unix)]
919 if std::path::Path::new("/dev/zero").exists() {
920 let start = std::time::Instant::now();
921 let err = ReadTool
922 .execute_rich(json!({"path": "/dev/zero"}), create_tool_context())
923 .await
924 .unwrap_err()
925 .to_string();
926 assert!(err.contains("not a regular file"), "{err}");
927 assert!(start.elapsed() < std::time::Duration::from_secs(2));
928 }
929 }
930
931 #[tokio::test]
932 async fn non_image_binary_mentions_image_support() {
933 let p = tmp("blob.bin", &[0xFF, 0xFE, 0x00, 0x80, 0x81]);
934 let err = ReadTool
935 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
936 .await
937 .unwrap_err();
938 assert!(
939 err.to_string().contains("returned as images automatically"),
940 "{err}"
941 );
942 let _ = std::fs::remove_file(p);
943 }
944}