1use super::{resolve_path_in, 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 = resolve_path_in(raw_path, ctx.capabilities.cwd.as_deref());
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
255pub(crate) fn image_output(
256 path: &std::path::Path,
257 mime: &'static str,
258 bytes: &[u8],
259) -> Result<ToolOutput> {
260 use base64::Engine as _;
261 let kb = bytes.len().div_ceil(1024);
262 if bytes.len() > MAX_IMAGE_BYTES {
263 return Err(RuntimeError::Tool(format!(
264 "Image '{p}' is {kb} KB; the read tool accepts images up to {cap} KB. \
265 Shrink it with bash and read the new file, e.g.:\n \
266 convert '{p}' -resize 1568x1568\\> -quality 85 /tmp/shrunk.jpg (ImageMagick)\n \
267 sips -Z 1568 '{p}' --out /tmp/shrunk.png (macOS)",
268 p = path.display(),
269 kb = kb,
270 cap = MAX_IMAGE_BYTES / 1024
271 )));
272 }
273 if let Some(why) = image_integrity_error(mime, bytes) {
274 return Err(RuntimeError::Tool(format!(
275 "Image '{}' ({mime}, {kb} KB) appears truncated or corrupt: {why}. \
276 Not sent to the model. Verify with `file` / `identify`, or re-export it and read the new file.",
277 path.display()
278 )));
279 }
280 let Some((w, h)) = image_dimensions(mime, bytes) else {
283 return Err(RuntimeError::Tool(format!(
284 "Image '{}' ({mime}, {kb} KB): could not parse dimensions from the header. \
285 Not sent to the model. Re-export it (e.g. `convert '{}' /tmp/fixed.png`) and read the new file.",
286 path.display(),
287 path.display()
288 )));
289 };
290 if w == 0 || h == 0 {
293 return Err(RuntimeError::Tool(format!(
294 "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.",
295 path.display()
296 )));
297 }
298 if w > MAX_IMAGE_SIDE_PX || h > MAX_IMAGE_SIDE_PX {
299 return Err(RuntimeError::Tool(format!(
300 "Image '{}' is {w}x{h}; the provider rejects images with any side over {MAX_IMAGE_SIDE_PX} px. \
301 Downscale it with bash (e.g. `convert ... -resize 1568x1568\\>`) and read the new file.",
302 path.display()
303 )));
304 }
305 let mut summary = format!("Image: {} ({w}x{h}, {mime}, {kb} KB)", path.display());
306 if w.max(h) > PROVIDER_DOWNSCALE_LONG_EDGE {
307 summary.push_str("\nNote: long edge exceeds 1568 px; the provider downscales before viewing, so pixel coordinates read from the image are approximate.");
308 }
309 let data = base64::engine::general_purpose::STANDARD.encode(bytes);
310 Ok(ToolOutput::Blocks {
311 blocks: vec![
312 json!({ "type": "text", "text": summary }),
314 json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": data } }),
315 ],
316 summary,
317 })
318}
319
320#[cfg(test)]
321mod tests {
322 use super::super::test_helpers::create_tool_context;
323 use super::*;
324 use crate::tools::Tool;
325 use serde_json::json;
326
327 #[test]
328 fn test_read_tool_schema() {
329 let tool = ReadTool;
330 assert_eq!(tool.name(), "read");
331 assert!(!tool.description().is_empty());
332
333 let params = tool.parameters();
334 assert_eq!(params["type"], "object");
335 assert!(params["properties"].is_object());
336 assert!(params["required"].is_array());
337 }
338
339 #[tokio::test]
340 async fn test_read_tool_execution() {
341 let temp_dir = std::env::temp_dir();
342 let test_file = temp_dir.join("read_tool_test.txt");
343
344 let content = "line 1\nline 2\nline 3\nline 4\nline 5";
346 std::fs::write(&test_file, content).unwrap();
347
348 let tool = ReadTool;
349 let ctx = create_tool_context();
350
351 let params = json!({
353 "path": test_file.to_string_lossy()
354 });
355 let result = tool.execute(params, ctx).await.unwrap();
356
357 assert!(result.contains("1\tline 1"));
359 assert!(result.contains("2\tline 2"));
360 assert!(result.contains("5\tline 5"));
361
362 let ctx = create_tool_context();
364 let params = json!({
365 "path": test_file.to_string_lossy(),
366 "offset": 2,
367 "limit": 2
368 });
369 let result = tool.execute(params, ctx).await.unwrap();
370
371 assert!(result.contains("3\tline 3"));
372 assert!(result.contains("4\tline 4"));
373 assert!(!result.contains("1\tline 1"));
374 assert!(!result.contains("5\tline 5"));
375
376 let _ = std::fs::remove_file(&test_file);
378 }
379
380 #[tokio::test]
381 async fn test_read_tool_offset() {
382 let temp_dir = std::env::temp_dir();
383 let test_file = temp_dir.join("test_read_tool_offset.txt");
384
385 let lines = (1..=10).map(|i| format!("line {}", i)).collect::<Vec<_>>();
387 let content = lines.join("\n");
388 std::fs::write(&test_file, &content).unwrap();
389
390 let tool = ReadTool;
391 let ctx = create_tool_context();
392
393 let params = json!({
395 "path": test_file.to_string_lossy(),
396 "offset": 5
397 });
398
399 let result = tool.execute(params, ctx).await.unwrap();
400
401 assert!(result.contains("6\tline 6"));
403 assert!(!result.contains("1\tline 1"));
405 assert!(!result.contains("5\tline 5"));
406
407 let _ = std::fs::remove_file(&test_file);
409 }
410
411 const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
414
415 fn png_header(w: u32, h: u32) -> Vec<u8> {
417 let mut v = PNG_SIG.to_vec();
418 v.extend_from_slice(&13u32.to_be_bytes()); v.extend_from_slice(b"IHDR");
420 v.extend_from_slice(&w.to_be_bytes());
421 v.extend_from_slice(&h.to_be_bytes());
422 v
423 }
424
425 const PNG_IEND: [u8; 12] = [0, 0, 0, 0, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82];
426
427 fn png_with_dims(w: u32, h: u32) -> Vec<u8> {
429 let mut v = png_header(w, h);
430 v.extend_from_slice(&[8, 6, 0, 0, 0]); v.extend_from_slice(&[0, 0, 0, 0]); v.extend_from_slice(&PNG_IEND);
433 v
434 }
435
436 fn tiny_png() -> Vec<u8> {
438 png_with_dims(1, 1)
439 }
440
441 fn tmp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
442 let p = std::env::temp_dir().join(format!("read_img_{}_{name}", std::process::id()));
443 std::fs::write(&p, bytes).unwrap();
444 p
445 }
446
447 #[test]
448 fn sniff_png_jpeg_gif_webp() {
449 assert_eq!(sniff_image_mime(&PNG_SIG), Some("image/png"));
450 assert_eq!(
451 sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
452 Some("image/jpeg")
453 );
454 assert_eq!(
455 sniff_image_mime(b"GIF89a\x01\x00\x01\x00"),
456 Some("image/gif")
457 );
458 assert_eq!(
459 sniff_image_mime(b"GIF87a\x01\x00\x01\x00"),
460 Some("image/gif")
461 );
462 assert_eq!(
463 sniff_image_mime(b"RIFF\x00\x00\x00\x00WEBPVP8 "),
464 Some("image/webp")
465 );
466 assert_eq!(sniff_image_mime(b"hello"), None);
467 assert_eq!(
468 sniff_image_mime(b"BM\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"),
469 None
470 );
471 assert_eq!(sniff_image_mime(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3"), None);
472 assert_eq!(sniff_image_mime(b"RIFF\x00\x00\x00\x00WAVEfmt "), None);
473 assert_eq!(sniff_image_mime(b""), None);
474 }
475
476 #[tokio::test]
477 async fn sniff_ignores_extension() {
478 let text_as_png = tmp("notes.png", b"just text\nline two");
479 let out = ReadTool
480 .execute_rich(
481 json!({"path": text_as_png.to_string_lossy()}),
482 create_tool_context(),
483 )
484 .await
485 .unwrap();
486 match out {
487 ToolOutput::Text(s) => assert!(s.contains("1\tjust text"), "{s}"),
488 other => panic!("expected Text, got {other:?}"),
489 }
490
491 let png_as_txt = tmp("data.txt", &tiny_png());
492 let out = ReadTool
493 .execute_rich(
494 json!({"path": png_as_txt.to_string_lossy()}),
495 create_tool_context(),
496 )
497 .await
498 .unwrap();
499 assert!(matches!(out, ToolOutput::Blocks { .. }), "{out:?}");
500 let _ = std::fs::remove_file(text_as_png);
501 let _ = std::fs::remove_file(png_as_txt);
502 }
503
504 #[test]
505 fn png_dimensions_from_ihdr() {
506 assert_eq!(image_dimensions("image/png", &tiny_png()), Some((1, 1)));
507 assert_eq!(
508 image_dimensions("image/png", &png_header(1024, 1536)),
509 Some((1024, 1536))
510 );
511 assert_eq!(image_dimensions("image/png", &PNG_SIG), None);
512 }
513
514 #[test]
515 fn jpeg_dimensions_from_sof0() {
516 let mut j = vec![0xFF, 0xD8]; j.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); j.extend_from_slice(b"JFIF\0");
519 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]);
524 assert_eq!(image_dimensions("image/jpeg", &j), Some((640, 480)));
525
526 let sos_only = [0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0];
528 assert_eq!(image_dimensions("image/jpeg", &sos_only), None);
529
530 let trunc = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x01];
532 assert_eq!(image_dimensions("image/jpeg", &trunc), None);
533
534 let mut seed = 0x9E37_79B9u32;
536 for _ in 0..200 {
537 let mut buf = vec![0xFF, 0xD8, 0xFF];
538 for _ in 0..64 {
539 seed ^= seed << 13;
540 seed ^= seed >> 17;
541 seed ^= seed << 5;
542 buf.push((seed & 0xFF) as u8);
543 }
544 let _ = image_dimensions("image/jpeg", &buf);
545 }
546 }
547
548 #[test]
549 fn webp_dimensions_vp8_vp8l_vp8x() {
550 let mut vp8 = b"RIFF\x00\x00\x00\x00WEBPVP8 \x00\x00\x00\x00".to_vec();
552 vp8.extend_from_slice(&[0x00, 0x00, 0x00, 0x9D, 0x01, 0x2A]);
553 vp8.extend_from_slice(&320u16.to_le_bytes());
554 vp8.extend_from_slice(&240u16.to_le_bytes());
555 assert_eq!(image_dimensions("image/webp", &vp8), Some((320, 240)));
556
557 let (w, h) = (100u32, 50u32);
559 let bits: u32 = (w - 1) | ((h - 1) << 14);
560 let mut vp8l = b"RIFF\x00\x00\x00\x00WEBPVP8L\x00\x00\x00\x00\x2F".to_vec();
561 vp8l.extend_from_slice(&bits.to_le_bytes());
562 vp8l.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
563 assert_eq!(image_dimensions("image/webp", &vp8l), Some((100, 50)));
564
565 let mut vp8x = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0A\x00\x00\x00".to_vec();
567 vp8x.extend_from_slice(&[0, 0, 0, 0]);
568 vp8x.extend_from_slice(&(1999u32).to_le_bytes()[..3]);
569 vp8x.extend_from_slice(&(999u32).to_le_bytes()[..3]);
570 assert_eq!(image_dimensions("image/webp", &vp8x), Some((2000, 1000)));
571 }
572
573 #[tokio::test]
574 async fn image_read_block_ordering() {
575 use base64::Engine as _;
576 let bytes = tiny_png();
577 let p = tmp("order.png", &bytes);
578 let out = ReadTool
579 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
580 .await
581 .unwrap();
582 let ToolOutput::Blocks { blocks, summary } = out else {
583 panic!("expected Blocks");
584 };
585 assert_eq!(blocks.len(), 2);
586 assert_eq!(blocks[0]["type"], "text");
587 assert_eq!(blocks[0]["text"], summary);
588 assert_eq!(blocks[1]["type"], "image");
589 assert_eq!(blocks[1]["source"]["type"], "base64");
590 assert_eq!(blocks[1]["source"]["media_type"], "image/png");
591 let decoded = base64::engine::general_purpose::STANDARD
592 .decode(blocks[1]["source"]["data"].as_str().unwrap())
593 .unwrap();
594 assert_eq!(decoded, bytes);
595 let _ = std::fs::remove_file(p);
596 }
597
598 #[tokio::test]
599 async fn image_over_cap_rejected() {
600 let mut bytes = png_header(10, 10);
601 bytes.resize(MAX_IMAGE_BYTES + 1, 0);
602 let p = tmp("big.png", &bytes);
603 let err = ReadTool
604 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
605 .await
606 .unwrap_err();
607 let RuntimeError::Tool(msg) = err else {
608 panic!("expected Tool error, got {err:?}");
609 };
610 assert!(msg.contains("convert"), "{msg}");
611 assert!(msg.contains("KB"), "{msg}");
612 assert!(msg.len() < 1024, "{}", msg.len());
613 let _ = std::fs::remove_file(p);
614 }
615
616 #[tokio::test]
617 async fn image_over_8000px_rejected() {
618 let p = tmp("wide.png", &png_with_dims(8001, 10));
619 let err = ReadTool
620 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
621 .await
622 .unwrap_err();
623 assert!(err.to_string().contains("8000"), "{err}");
624 let _ = std::fs::remove_file(p);
625 }
626
627 #[tokio::test]
628 async fn legacy_execute_returns_summary_for_images() {
629 let p = tmp("legacy.png", &tiny_png());
630 let s = ReadTool
631 .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
632 .await
633 .unwrap();
634 assert!(s.starts_with("Image: "), "{s}");
635 assert!(s.len() < 512);
636 let _ = std::fs::remove_file(p);
637 }
638
639 #[tokio::test]
640 async fn label_format() {
641 let p = tmp("label.png", &tiny_png());
642 let out = ReadTool
643 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
644 .await
645 .unwrap();
646 assert_eq!(
647 out.summary(),
648 format!("Image: {} (1x1, image/png, 1 KB)", p.display())
649 );
650 let _ = std::fs::remove_file(p);
651
652 let p = tmp("wide2.png", &png_with_dims(2000, 100));
653 let out = ReadTool
654 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
655 .await
656 .unwrap();
657 assert!(out.summary().starts_with(&format!(
658 "Image: {} (2000x100, image/png, 1 KB)",
659 p.display()
660 )));
661 assert!(out.summary().contains("1568 px"), "{}", out.summary());
662 let _ = std::fs::remove_file(p);
663 }
664
665 #[tokio::test]
668 async fn real_avatar_png_round_trips_as_image_blocks() {
669 let Some(home) = std::env::var_os("HOME") else {
670 return;
671 };
672 let p = std::path::PathBuf::from(home).join("Jawz/media/jawz-avatar-v1.png");
673 if !p.exists() {
674 eprintln!(
675 "SKIPPED real_avatar_png_round_trips_as_image_blocks: {} absent",
676 p.display()
677 );
678 return;
679 }
680 let out = ReadTool
681 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
682 .await
683 .unwrap();
684 let ToolOutput::Blocks { blocks, summary } = out else {
685 panic!("expected Blocks");
686 };
687 assert!(
688 summary.starts_with(&format!(
689 "Image: {} (1024x1536, image/png, 2292 KB)",
690 p.display()
691 )),
692 "{summary}"
693 );
694 assert_eq!(blocks[0]["text"], summary);
695 assert_eq!(blocks[1]["source"]["media_type"], "image/png");
696 let data = blocks[1]["source"]["data"].as_str().unwrap();
697 assert!(data.starts_with("iVBORw0KGgo"));
698 assert!(serde_json::to_vec(&blocks[1]).unwrap().len() < 5_000_000);
699 }
700
701 #[tokio::test]
705 async fn real_rs_file_and_optional_big_image() {
706 let out = ReadTool
707 .execute_rich(
708 json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/src/tools/read.rs"), "limit": 3}),
709 create_tool_context(),
710 )
711 .await
712 .unwrap();
713 let ToolOutput::Text(text) = out else {
714 panic!("expected Text")
715 };
716 assert!(text.starts_with("1\tuse super::"), "{text}");
717
718 let Ok(big) = std::env::var("SYNAPS_TEST_BIG_IMAGE") else {
719 eprintln!("SKIPPED big-image half of real_rs_file_and_optional_big_image: SYNAPS_TEST_BIG_IMAGE unset");
720 return;
721 };
722 let err = ReadTool
723 .execute_rich(json!({"path": big}), create_tool_context())
724 .await
725 .unwrap_err()
726 .to_string();
727 assert!(err.contains("accepts images up to 3584 KB"), "{err}");
728 assert!(err.contains("convert"));
729 assert!(err.len() < 1024);
730 }
731
732 #[test]
735 fn integrity_check_per_format() {
736 assert_eq!(image_integrity_error("image/png", &tiny_png()), None);
738 assert!(image_integrity_error("image/png", &png_header(1, 1)).is_some()); let mut bad_chunk = tiny_png();
740 bad_chunk[12..16].copy_from_slice(b"iTXt");
741 assert!(image_integrity_error("image/png", &bad_chunk).is_some());
742 assert!(image_integrity_error("image/png", &PNG_SIG).is_some());
743 assert_eq!(
745 image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xD9]),
746 None
747 );
748 assert!(image_integrity_error("image/jpeg", &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0]).is_some());
749 assert_eq!(
751 image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00\x3B"),
752 None
753 );
754 assert!(image_integrity_error("image/gif", b"GIF89a\x01\x00\x01\x00").is_some());
755 let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ".to_vec();
757 webp.extend_from_slice(&[0u8; 20]);
758 assert!(image_integrity_error("image/webp", &webp).is_some());
759 let riff = (webp.len() - 8) as u32;
760 webp[4..8].copy_from_slice(&riff.to_le_bytes());
761 assert_eq!(image_integrity_error("image/webp", &webp), None);
762 webp.pop();
763 assert!(image_integrity_error("image/webp", &webp).is_some());
764 }
765
766 #[tokio::test]
767 async fn truncated_png_rejected_no_image_block() {
768 let mut bytes = png_header(10, 10);
770 bytes.resize(3_000, 0);
771 let p = tmp("trunc.png", &bytes);
772 let err = ReadTool
773 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
774 .await
775 .unwrap_err();
776 let RuntimeError::Tool(msg) = err else {
777 panic!("expected Tool error, got {err:?}");
778 };
779 assert!(msg.contains("truncated or corrupt"), "{msg}");
780 assert!(msg.contains("IEND"), "{msg}");
781 assert!(!msg.contains("iVBORw0KGgo"), "no base64 in error");
782 assert!(msg.len() < 1024);
783 let s = ReadTool
785 .execute(json!({"path": p.to_string_lossy()}), create_tool_context())
786 .await
787 .unwrap_err()
788 .to_string();
789 assert!(s.contains("truncated or corrupt"), "{s}");
790 let _ = std::fs::remove_file(p);
791 }
792
793 #[tokio::test]
794 async fn truncated_jpeg_and_gif_rejected() {
795 let jpeg = [
796 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, 1, 1, 0, 0, 1, 0, 1, 0,
797 0,
798 ];
799 let p = tmp("trunc.jpg", &jpeg);
800 let err = ReadTool
801 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
802 .await
803 .unwrap_err()
804 .to_string();
805 assert!(err.contains("EOI"), "{err}");
806 let _ = std::fs::remove_file(p);
807
808 let p = tmp("trunc.gif", b"GIF89a\x01\x00\x01\x00\x00\x00\x00");
809 let err = ReadTool
810 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
811 .await
812 .unwrap_err()
813 .to_string();
814 assert!(err.contains("GIF trailer"), "{err}");
815 let _ = std::fs::remove_file(p);
816 }
817
818 #[tokio::test]
819 async fn unparseable_dims_rejected_not_shipped() {
820 let jpeg = [
823 0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x08, 0, 0, 0, 0, 0, 0, 0xFF, 0xD9,
824 ];
825 let p = tmp("nodims.jpg", &jpeg);
826 let err = ReadTool
827 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
828 .await
829 .unwrap_err()
830 .to_string();
831 assert!(err.contains("could not parse dimensions"), "{err}");
832 assert!(err.contains("Not sent to the model"), "{err}");
833 let _ = std::fs::remove_file(p);
834 }
835
836 #[tokio::test]
837 async fn zero_dimension_images_rejected_not_shipped() {
838 for (name, w, h) in [
839 ("w0.png", 0u32, 7u32),
840 ("h0.png", 7, 0),
841 ("both0.png", 0, 0),
842 ] {
843 let p = tmp(name, &png_with_dims(w, h));
844 let err = ReadTool
845 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
846 .await
847 .unwrap_err()
848 .to_string();
849 assert!(err.contains("zero-sized"), "{name}: {err}");
850 assert!(err.contains(&format!("{w}x{h}")), "{name}: {err}");
851 assert!(err.contains("Not sent to the model"), "{name}: {err}");
852 let _ = std::fs::remove_file(p);
853 }
854 }
855
856 #[tokio::test]
857 async fn complete_gif_and_jpeg_round_trip_through_execute_rich() {
858 let p = tmp("ok.gif", b"GIF89a\x02\x00\x03\x00\x00\x00\x00\x3B");
859 let out = ReadTool
860 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
861 .await
862 .unwrap();
863 assert!(
864 out.summary().contains("(2x3, image/gif, 1 KB)"),
865 "{}",
866 out.summary()
867 );
868 assert!(matches!(out, ToolOutput::Blocks { .. }));
869 let _ = std::fs::remove_file(p);
870
871 let mut j = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08];
872 j.extend_from_slice(&480u16.to_be_bytes());
873 j.extend_from_slice(&640u16.to_be_bytes());
874 j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1, 0xFF, 0xD9]);
875 let p = tmp("ok.jpg", &j);
876 let out = ReadTool
877 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
878 .await
879 .unwrap();
880 assert!(
881 out.summary().contains("(640x480, image/jpeg, 1 KB)"),
882 "{}",
883 out.summary()
884 );
885 let _ = std::fs::remove_file(p);
886 }
887
888 #[tokio::test]
891 async fn oversize_file_rejected_by_metadata_without_reading() {
892 let p = std::env::temp_dir().join(format!("read_sparse_{}.bin", std::process::id()));
895 let f = std::fs::File::create(&p).unwrap();
896 f.set_len(MAX_READ_BYTES + 1).unwrap();
897 drop(f);
898 let start = std::time::Instant::now();
899 let err = ReadTool
900 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
901 .await
902 .unwrap_err()
903 .to_string();
904 assert!(err.contains("refuses files over"), "{err}");
905 assert!(err.contains("head"), "{err}");
906 assert!(start.elapsed() < std::time::Duration::from_secs(2));
907 let _ = std::fs::remove_file(p);
908 }
909
910 #[tokio::test]
911 async fn directory_and_device_rejected_as_not_regular_file() {
912 let err = ReadTool
913 .execute_rich(
914 json!({"path": std::env::temp_dir().to_string_lossy()}),
915 create_tool_context(),
916 )
917 .await
918 .unwrap_err()
919 .to_string();
920 assert!(err.contains("not a regular file"), "{err}");
921
922 #[cfg(unix)]
923 if std::path::Path::new("/dev/zero").exists() {
924 let start = std::time::Instant::now();
925 let err = ReadTool
926 .execute_rich(json!({"path": "/dev/zero"}), create_tool_context())
927 .await
928 .unwrap_err()
929 .to_string();
930 assert!(err.contains("not a regular file"), "{err}");
931 assert!(start.elapsed() < std::time::Duration::from_secs(2));
932 }
933 }
934
935 #[tokio::test]
936 async fn non_image_binary_mentions_image_support() {
937 let p = tmp("blob.bin", &[0xFF, 0xFE, 0x00, 0x80, 0x81]);
938 let err = ReadTool
939 .execute_rich(json!({"path": p.to_string_lossy()}), create_tool_context())
940 .await
941 .unwrap_err();
942 assert!(
943 err.to_string().contains("returned as images automatically"),
944 "{err}"
945 );
946 let _ = std::fs::remove_file(p);
947 }
948}