Skip to main content

zeroclaw_tools/
file_upload.rs

1use async_trait::async_trait;
2use futures_util::StreamExt;
3use serde_json::json;
4use std::sync::Arc;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::policy::SecurityPolicy;
7use zeroclaw_config::schema::FileUploadConfig;
8
9const RESPONSE_BODY_LIMIT_BYTES: usize = 4 * 1024;
10
11pub struct FileUploadTool {
12    security: Arc<SecurityPolicy>,
13    config: FileUploadConfig,
14}
15
16impl FileUploadTool {
17    pub fn new(security: Arc<SecurityPolicy>, config: FileUploadConfig) -> Self {
18        Self { security, config }
19    }
20
21    /// Best-effort MIME detection. Tries content-sniffing on the first bytes
22    /// (catches binary files with wrong or missing extensions), then falls
23    /// back to a filename-extension table for text formats and types `infer`
24    /// does not cover, then finally to `application/octet-stream`.
25    fn detect_mime(bytes: &[u8], file_name: &str) -> &'static str {
26        if let Some(kind) = infer::get(bytes) {
27            return kind.mime_type();
28        }
29        Self::mime_for_filename(file_name)
30    }
31
32    fn mime_for_filename(name: &str) -> &'static str {
33        let ext = name
34            .rsplit_once('.')
35            .map(|(_, e)| e.to_ascii_lowercase())
36            .unwrap_or_default();
37        match ext.as_str() {
38            // Images
39            "png" => "image/png",
40            "jpg" | "jpeg" => "image/jpeg",
41            "gif" => "image/gif",
42            "webp" => "image/webp",
43            "bmp" => "image/bmp",
44            "tiff" | "tif" => "image/tiff",
45            "svg" => "image/svg+xml",
46            "heic" => "image/heic",
47            "avif" => "image/avif",
48            "ico" => "image/x-icon",
49            // Documents
50            "pdf" => "application/pdf",
51            "rtf" => "application/rtf",
52            "doc" => "application/msword",
53            "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
54            "xls" => "application/vnd.ms-excel",
55            "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
56            "ppt" => "application/vnd.ms-powerpoint",
57            "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
58            "odt" => "application/vnd.oasis.opendocument.text",
59            "ods" => "application/vnd.oasis.opendocument.spreadsheet",
60            "epub" => "application/epub+zip",
61            // Data / structured
62            "json" => "application/json",
63            "xml" => "application/xml",
64            "yaml" | "yml" => "application/yaml",
65            "toml" => "application/toml",
66            "sql" => "application/sql",
67            // Archives
68            "zip" => "application/zip",
69            "tar" => "application/x-tar",
70            "gz" | "tgz" => "application/gzip",
71            "bz2" => "application/x-bzip2",
72            "xz" => "application/x-xz",
73            "7z" => "application/x-7z-compressed",
74            "rar" => "application/vnd.rar",
75            // Code / text
76            "txt" | "log" => "text/plain",
77            "md" | "markdown" => "text/markdown",
78            "csv" => "text/csv",
79            "tsv" => "text/tab-separated-values",
80            "html" | "htm" => "text/html",
81            "css" => "text/css",
82            "js" | "mjs" | "cjs" => "application/javascript",
83            "ts" => "application/typescript",
84            "rs" => "text/x-rust",
85            "py" => "text/x-python",
86            "sh" | "bash" => "application/x-sh",
87            // Audio
88            "mp3" => "audio/mpeg",
89            "wav" => "audio/wav",
90            "ogg" | "oga" | "opus" => "audio/ogg",
91            "flac" => "audio/flac",
92            // Video
93            "m4a" | "mp4" => "video/mp4",
94            "webm" => "video/webm",
95            "mov" => "video/quicktime",
96            "mkv" => "video/x-matroska",
97            "avi" => "video/x-msvideo",
98            // Fonts
99            "woff" => "font/woff",
100            "woff2" => "font/woff2",
101            "ttf" => "font/ttf",
102            "otf" => "font/otf",
103            _ => "application/octet-stream",
104        }
105    }
106
107    /// Stream the receiver's response body into memory while never buffering
108    /// more than `RESPONSE_BODY_LIMIT_BYTES` (+1 sentinel byte to detect that
109    /// more was available). The response comes from the operator-configured
110    /// endpoint and is untrusted: a misbehaving or hostile receiver must not be
111    /// able to make the tool read an unbounded body into memory just to surface
112    /// a small preview. Mirrors the bounded-read shape used by `web_fetch`.
113    async fn read_response_body_capped(response: reqwest::Response) -> Vec<u8> {
114        let hard_cap = RESPONSE_BODY_LIMIT_BYTES.saturating_add(1);
115        let mut bytes = Vec::new();
116        let mut stream = response.bytes_stream();
117        while let Some(chunk) = stream.next().await {
118            // A mid-stream read error simply ends the body; the HTTP status was
119            // already captured from the response head before reading.
120            let Ok(chunk) = chunk else { break };
121            let remaining = hard_cap - bytes.len();
122            if chunk.len() >= remaining {
123                bytes.extend_from_slice(&chunk[..remaining]);
124                break;
125            }
126            bytes.extend_from_slice(&chunk);
127        }
128        bytes
129    }
130
131    /// Shape a (already byte-bounded) response body into a preview of at most
132    /// `RESPONSE_BODY_LIMIT_BYTES`, snapping the cut *down* to the nearest UTF-8
133    /// character boundary so a multi-byte character straddling the limit cannot
134    /// panic the slice (`&body[..n]` requires `n` to be a char boundary). The
135    /// caller bounds the read via [`Self::read_response_body_capped`]; this only
136    /// trims the display text and flags that the body was longer than the limit.
137    fn truncate_response_body(body: &str) -> String {
138        if body.len() <= RESPONSE_BODY_LIMIT_BYTES {
139            return body.to_string();
140        }
141        // A UTF-8 code point is at most 4 bytes, so this steps back at most 3.
142        let mut end = RESPONSE_BODY_LIMIT_BYTES;
143        while end > 0 && !body.is_char_boundary(end) {
144            end -= 1;
145        }
146        format!("{}... [truncated]", &body[..end])
147    }
148}
149
150#[async_trait]
151impl Tool for FileUploadTool {
152    fn name(&self) -> &str {
153        "file_upload"
154    }
155
156    fn description(&self) -> &str {
157        "Upload a local file to the configured remote endpoint via multipart/form-data. \
158         The file path stays on the host; bytes are not loaded into model context. \
159         Returns the HTTP status and a truncated response body so the caller can extract \
160         any URL or identifier the receiver echoes back."
161    }
162
163    fn parameters_schema(&self) -> serde_json::Value {
164        json!({
165            "type": "object",
166            "properties": {
167                "file_path": {
168                    "type": "string",
169                    "description": "Path to the file on the agent's filesystem. Relative paths resolve from the workspace."
170                }
171            },
172            "required": ["file_path"]
173        })
174    }
175
176    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
177        let Some(url) = self
178            .config
179            .url
180            .as_deref()
181            .map(str::trim)
182            .filter(|s| !s.is_empty())
183        else {
184            return Ok(ToolResult {
185                success: false,
186                output: String::new(),
187                error: Some("file_upload is disabled: [file_upload].url is not configured".into()),
188            });
189        };
190
191        let method = self.config.method.to_ascii_uppercase();
192        if method != "POST" && method != "PUT" {
193            return Ok(ToolResult {
194                success: false,
195                output: String::new(),
196                error: Some(format!(
197                    "Unsupported HTTP method '{method}'. Only POST and PUT are allowed."
198                )),
199            });
200        }
201
202        if !self.security.can_act() {
203            return Ok(ToolResult {
204                success: false,
205                output: String::new(),
206                error: Some("Action blocked: autonomy is read-only".into()),
207            });
208        }
209
210        if self.security.is_rate_limited() {
211            return Ok(ToolResult {
212                success: false,
213                output: String::new(),
214                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
215            });
216        }
217
218        let path = args
219            .get("file_path")
220            .and_then(|v| v.as_str())
221            .map(str::trim)
222            .filter(|s| !s.is_empty())
223            .ok_or_else(|| anyhow::Error::msg("Missing 'file_path' parameter"))?;
224
225        if !self.security.is_path_allowed(path) {
226            return Ok(ToolResult {
227                success: false,
228                output: String::new(),
229                error: Some(format!("Path not allowed by security policy: {path}")),
230            });
231        }
232
233        if !self.security.record_action() {
234            return Ok(ToolResult {
235                success: false,
236                output: String::new(),
237                error: Some("Rate limit exceeded: action budget exhausted".into()),
238            });
239        }
240
241        let full_path = self.security.resolve_tool_path(path);
242
243        let resolved_path = match tokio::fs::canonicalize(&full_path).await {
244            Ok(p) => p,
245            Err(e) => {
246                return Ok(ToolResult {
247                    success: false,
248                    output: String::new(),
249                    error: Some(format!("Failed to resolve file path: {e}")),
250                });
251            }
252        };
253
254        if !self.security.is_resolved_path_allowed(&resolved_path) {
255            return Ok(ToolResult {
256                success: false,
257                output: String::new(),
258                error: Some(
259                    self.security
260                        .resolved_path_violation_message(&resolved_path),
261                ),
262            });
263        }
264
265        let metadata = match tokio::fs::metadata(&resolved_path).await {
266            Ok(m) => m,
267            Err(e) => {
268                return Ok(ToolResult {
269                    success: false,
270                    output: String::new(),
271                    error: Some(format!("Failed to read file metadata: {e}")),
272                });
273            }
274        };
275
276        if !metadata.is_file() {
277            return Ok(ToolResult {
278                success: false,
279                output: String::new(),
280                error: Some(format!("Not a regular file: {}", resolved_path.display())),
281            });
282        }
283
284        if metadata.len() > self.config.max_file_size_bytes {
285            return Ok(ToolResult {
286                success: false,
287                output: String::new(),
288                error: Some(format!(
289                    "File too large: {} bytes (limit: {} bytes)",
290                    metadata.len(),
291                    self.config.max_file_size_bytes
292                )),
293            });
294        }
295
296        let bytes = match tokio::fs::read(&resolved_path).await {
297            Ok(b) => b,
298            Err(e) => {
299                return Ok(ToolResult {
300                    success: false,
301                    output: String::new(),
302                    error: Some(format!("Failed to read file: {e}")),
303                });
304            }
305        };
306
307        // Re-check against the bytes actually read. The metadata guard above can
308        // be defeated if the file grows between `metadata()` and `read()` (or for
309        // sources whose pre-read size is unreliable), so enforce the cap on the
310        // payload that would actually hit the network before building the body.
311        if bytes.len() as u64 > self.config.max_file_size_bytes {
312            return Ok(ToolResult {
313                success: false,
314                output: String::new(),
315                error: Some(format!(
316                    "File too large after read: {} bytes (limit: {} bytes)",
317                    bytes.len(),
318                    self.config.max_file_size_bytes
319                )),
320            });
321        }
322
323        let file_name = resolved_path
324            .file_name()
325            .and_then(|s| s.to_str())
326            .unwrap_or("upload")
327            .to_string();
328        let mime = Self::detect_mime(&bytes, &file_name);
329
330        let part = match reqwest::multipart::Part::bytes(bytes)
331            .file_name(file_name.clone())
332            .mime_str(mime)
333        {
334            Ok(p) => p,
335            Err(e) => {
336                return Ok(ToolResult {
337                    success: false,
338                    output: String::new(),
339                    error: Some(format!("Failed to build multipart part: {e}")),
340                });
341            }
342        };
343
344        let form = reqwest::multipart::Form::new().part(self.config.field_name.clone(), part);
345
346        let client = zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
347            "tool.file_upload",
348            self.config.timeout_secs,
349            10,
350        );
351
352        let mut request = if method == "PUT" {
353            client.put(url)
354        } else {
355            client.post(url)
356        };
357
358        for (k, v) in &self.config.headers {
359            request = request.header(k.as_str(), v.as_str());
360        }
361
362        let response = match request.multipart(form).send().await {
363            Ok(r) => r,
364            Err(e) => {
365                return Ok(ToolResult {
366                    success: false,
367                    output: String::new(),
368                    error: Some(format!("Upload request failed: {e}")),
369                });
370            }
371        };
372
373        let status = response.status();
374        let raw_body = Self::read_response_body_capped(response).await;
375        let body = String::from_utf8_lossy(&raw_body);
376        let truncated = Self::truncate_response_body(&body);
377
378        if status.is_success() {
379            Ok(ToolResult {
380                success: true,
381                output: format!("Uploaded {file_name} ({status}). Response: {truncated}"),
382                error: None,
383            })
384        } else {
385            Ok(ToolResult {
386                success: false,
387                output: truncated,
388                error: Some(format!("Upload endpoint returned status {status}")),
389            })
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use std::collections::HashMap;
398    use std::fs;
399    use std::path::PathBuf;
400    use tempfile::TempDir;
401    use wiremock::matchers::{header, method, path};
402    use wiremock::{Mock, MockServer, ResponseTemplate};
403    use zeroclaw_config::autonomy::AutonomyLevel;
404
405    fn test_security(workspace: PathBuf, level: AutonomyLevel) -> Arc<SecurityPolicy> {
406        Arc::new(SecurityPolicy {
407            autonomy: level,
408            max_actions_per_hour: 100,
409            workspace_dir: workspace,
410            ..SecurityPolicy::default()
411        })
412    }
413
414    fn cfg(url: Option<String>) -> FileUploadConfig {
415        FileUploadConfig {
416            url,
417            ..FileUploadConfig::default()
418        }
419    }
420
421    #[test]
422    fn tool_name_and_description() {
423        let tmp = TempDir::new().unwrap();
424        let tool = FileUploadTool::new(
425            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
426            cfg(Some("https://example.com/upload".into())),
427        );
428        assert_eq!(tool.name(), "file_upload");
429        assert!(!tool.description().is_empty());
430    }
431
432    #[test]
433    fn schema_requires_file_path() {
434        let tmp = TempDir::new().unwrap();
435        let tool = FileUploadTool::new(
436            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
437            cfg(Some("https://example.com/upload".into())),
438        );
439        let schema = tool.parameters_schema();
440        assert_eq!(schema["type"], "object");
441        let required = schema["required"].as_array().unwrap();
442        assert!(required.contains(&serde_json::Value::String("file_path".into())));
443    }
444
445    #[tokio::test]
446    async fn execute_fails_when_url_unset() {
447        let tmp = TempDir::new().unwrap();
448        let file = tmp.path().join("hello.txt");
449        fs::write(&file, b"hello").unwrap();
450
451        let tool = FileUploadTool::new(
452            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
453            cfg(None),
454        );
455
456        let result = tool
457            .execute(json!({ "file_path": "hello.txt" }))
458            .await
459            .unwrap();
460        assert!(!result.success);
461        assert!(result.error.unwrap().contains("disabled"));
462    }
463
464    #[tokio::test]
465    async fn execute_blocks_readonly_autonomy() {
466        let tmp = TempDir::new().unwrap();
467        let file = tmp.path().join("hello.txt");
468        fs::write(&file, b"hello").unwrap();
469
470        let tool = FileUploadTool::new(
471            test_security(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly),
472            cfg(Some("https://example.com/upload".into())),
473        );
474
475        let result = tool
476            .execute(json!({ "file_path": "hello.txt" }))
477            .await
478            .unwrap();
479        assert!(!result.success);
480        assert!(result.error.unwrap().contains("read-only"));
481    }
482
483    #[tokio::test]
484    async fn execute_rejects_file_over_size_cap() {
485        let tmp = TempDir::new().unwrap();
486        let file = tmp.path().join("big.bin");
487        fs::write(&file, vec![0u8; 2048]).unwrap();
488
489        let mut config = cfg(Some("https://example.com/upload".into()));
490        config.max_file_size_bytes = 1024;
491
492        let tool = FileUploadTool::new(
493            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
494            config,
495        );
496
497        let result = tool
498            .execute(json!({ "file_path": "big.bin" }))
499            .await
500            .unwrap();
501        assert!(!result.success);
502        assert!(result.error.unwrap().contains("too large"));
503    }
504
505    #[tokio::test]
506    async fn execute_rejects_path_outside_workspace() {
507        let workspace = TempDir::new().unwrap();
508        let outside = TempDir::new().unwrap();
509        let file = outside.path().join("secret.txt");
510        fs::write(&file, b"nope").unwrap();
511
512        let tool = FileUploadTool::new(
513            test_security(workspace.path().to_path_buf(), AutonomyLevel::Full),
514            cfg(Some("https://example.com/upload".into())),
515        );
516
517        let result = tool
518            .execute(json!({ "file_path": file.to_string_lossy() }))
519            .await
520            .unwrap();
521        assert!(!result.success);
522    }
523
524    #[tokio::test]
525    async fn execute_uploads_with_multipart_and_headers() {
526        let server = MockServer::start().await;
527        let tmp = TempDir::new().unwrap();
528        let file = tmp.path().join("hello.txt");
529        fs::write(&file, b"hello world").unwrap();
530
531        Mock::given(method("POST"))
532            .and(path("/upload"))
533            .and(header("X-Auth", "Bearer xyz"))
534            .respond_with(
535                ResponseTemplate::new(201).set_body_string(r#"{"id":"abc123","ok":true}"#),
536            )
537            .expect(1)
538            .mount(&server)
539            .await;
540
541        let mut headers = HashMap::new();
542        headers.insert("X-Auth".into(), "Bearer xyz".into());
543        let config = FileUploadConfig {
544            url: Some(format!("{}/upload", server.uri())),
545            headers,
546            ..FileUploadConfig::default()
547        };
548
549        let tool = FileUploadTool::new(
550            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
551            config,
552        );
553
554        let result = tool
555            .execute(json!({ "file_path": "hello.txt" }))
556            .await
557            .unwrap();
558
559        assert!(result.success, "expected success, got {result:?}");
560        assert!(result.output.contains("hello.txt"));
561        assert!(result.output.contains("abc123"));
562    }
563
564    #[tokio::test]
565    async fn execute_reports_non_2xx_response() {
566        let server = MockServer::start().await;
567        let tmp = TempDir::new().unwrap();
568        let file = tmp.path().join("hello.txt");
569        fs::write(&file, b"hello").unwrap();
570
571        Mock::given(method("POST"))
572            .and(path("/upload"))
573            .respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
574            .expect(1)
575            .mount(&server)
576            .await;
577
578        let config = FileUploadConfig {
579            url: Some(format!("{}/upload", server.uri())),
580            ..FileUploadConfig::default()
581        };
582
583        let tool = FileUploadTool::new(
584            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
585            config,
586        );
587
588        let result = tool
589            .execute(json!({ "file_path": "hello.txt" }))
590            .await
591            .unwrap();
592        assert!(!result.success);
593        let err = result.error.unwrap();
594        assert!(err.contains("403"), "unexpected error: {err}");
595    }
596
597    #[test]
598    fn mime_table_covers_common_extensions() {
599        assert_eq!(FileUploadTool::mime_for_filename("a.png"), "image/png");
600        assert_eq!(
601            FileUploadTool::mime_for_filename("a.PDF"),
602            "application/pdf"
603        );
604        assert_eq!(
605            FileUploadTool::mime_for_filename("a.zip"),
606            "application/zip"
607        );
608        assert_eq!(
609            FileUploadTool::mime_for_filename("README.md"),
610            "text/markdown"
611        );
612        assert_eq!(
613            FileUploadTool::mime_for_filename("notes.markdown"),
614            "text/markdown"
615        );
616        assert_eq!(FileUploadTool::mime_for_filename("a.txt"), "text/plain");
617        assert_eq!(
618            FileUploadTool::mime_for_filename("config.yaml"),
619            "application/yaml"
620        );
621        assert_eq!(
622            FileUploadTool::mime_for_filename("Cargo.toml"),
623            "application/toml"
624        );
625        assert_eq!(
626            FileUploadTool::mime_for_filename("app.js"),
627            "application/javascript"
628        );
629        assert_eq!(
630            FileUploadTool::mime_for_filename("report.xlsx"),
631            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
632        );
633        assert_eq!(FileUploadTool::mime_for_filename("a.woff2"), "font/woff2");
634        assert_eq!(
635            FileUploadTool::mime_for_filename("noext"),
636            "application/octet-stream"
637        );
638    }
639
640    #[test]
641    fn detect_mime_uses_content_sniff_for_binary_with_wrong_extension() {
642        // PNG magic bytes — should win over the .tmp extension
643        let png = [
644            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
645        ];
646        assert_eq!(
647            FileUploadTool::detect_mime(&png, "screenshot.tmp"),
648            "image/png"
649        );
650
651        // PDF magic bytes
652        let pdf = b"%PDF-1.7\n";
653        assert_eq!(
654            FileUploadTool::detect_mime(pdf, "report.bin"),
655            "application/pdf"
656        );
657    }
658
659    #[test]
660    fn detect_mime_falls_back_to_extension_for_text_formats() {
661        // Markdown has no magic bytes; content-sniff returns None and we should
662        // pick up the extension-table mapping.
663        let md = b"# Title\n\nSome paragraph text.\n";
664        assert_eq!(
665            FileUploadTool::detect_mime(md, "README.md"),
666            "text/markdown"
667        );
668
669        // YAML similarly has no magic bytes.
670        let yaml = b"key: value\nother: 42\n";
671        assert_eq!(
672            FileUploadTool::detect_mime(yaml, "config.yaml"),
673            "application/yaml"
674        );
675    }
676
677    #[test]
678    fn detect_mime_falls_back_to_octet_stream_for_unknown() {
679        let bytes = b"\x00\x01\x02\x03unknown binary garbage";
680        assert_eq!(
681            FileUploadTool::detect_mime(bytes, "mystery.dat"),
682            "application/octet-stream"
683        );
684    }
685
686    #[test]
687    fn truncate_response_body_passes_short_bodies_through() {
688        assert_eq!(FileUploadTool::truncate_response_body("ok"), "ok");
689        // Multi-byte but under the limit: returned unchanged.
690        let small = "café ☕".to_string();
691        assert_eq!(FileUploadTool::truncate_response_body(&small), small);
692    }
693
694    #[test]
695    fn truncate_response_body_is_utf8_boundary_safe() {
696        // '€' is 3 bytes and 4096 is not a multiple of 3, so the byte limit
697        // lands inside a character — a naive `&body[..LIMIT]` slice would panic.
698        let body = "€".repeat(2000); // 6000 bytes, well over the 4 KiB cap
699        assert!(
700            !body.is_char_boundary(RESPONSE_BODY_LIMIT_BYTES),
701            "test precondition: limit must land mid-character"
702        );
703
704        let out = FileUploadTool::truncate_response_body(&body);
705
706        // No panic, and the cut snaps down to the last whole char that fits:
707        // floor(4096 / 3) = 1365 chars = 4095 bytes retained.
708        assert!(out.contains("[truncated]"), "got: {out}");
709        assert!(out.starts_with("€".repeat(1365).as_str()));
710        assert!(!out.starts_with("€".repeat(1366).as_str()));
711    }
712
713    #[tokio::test]
714    async fn execute_truncates_multibyte_response_without_panicking() {
715        let server = MockServer::start().await;
716        let tmp = TempDir::new().unwrap();
717        let file = tmp.path().join("hello.txt");
718        fs::write(&file, b"hello").unwrap();
719
720        // Valid UTF-8 response whose 4 KiB cut point falls mid-character. Before
721        // the boundary-safe truncation this panicked the tool path end to end.
722        let big_body = "€".repeat(2000);
723        Mock::given(method("POST"))
724            .and(path("/upload"))
725            .respond_with(ResponseTemplate::new(200).set_body_string(big_body))
726            .expect(1)
727            .mount(&server)
728            .await;
729
730        let config = FileUploadConfig {
731            url: Some(format!("{}/upload", server.uri())),
732            ..FileUploadConfig::default()
733        };
734        let tool = FileUploadTool::new(
735            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
736            config,
737        );
738
739        let result = tool
740            .execute(json!({ "file_path": "hello.txt" }))
741            .await
742            .unwrap();
743
744        assert!(result.success, "expected success, got {result:?}");
745        assert!(
746            result.output.contains("truncated"),
747            "got: {}",
748            result.output
749        );
750    }
751
752    #[tokio::test]
753    async fn execute_bounds_oversized_response_read() {
754        let server = MockServer::start().await;
755        let tmp = TempDir::new().unwrap();
756        let file = tmp.path().join("hello.txt");
757        fs::write(&file, b"hello").unwrap();
758
759        // ~3 MiB multi-byte response from a misbehaving receiver. The tool must
760        // not buffer or echo it back wholesale — it reads at most a bounded
761        // preview — and the cut still lands mid-'€', exercising the boundary-safe
762        // path on a capped read.
763        let huge_body = "€".repeat(1_000_000); // 3_000_000 bytes
764        Mock::given(method("POST"))
765            .and(path("/upload"))
766            .respond_with(ResponseTemplate::new(200).set_body_string(huge_body))
767            .expect(1)
768            .mount(&server)
769            .await;
770
771        let config = FileUploadConfig {
772            url: Some(format!("{}/upload", server.uri())),
773            ..FileUploadConfig::default()
774        };
775        let tool = FileUploadTool::new(
776            test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
777            config,
778        );
779
780        let result = tool
781            .execute(json!({ "file_path": "hello.txt" }))
782            .await
783            .unwrap();
784
785        assert!(result.success, "expected success, got {result:?}");
786        assert!(
787            result.output.contains("truncated"),
788            "got: {}",
789            result.output
790        );
791        // The multi-megabyte receiver body must not flow through into the tool
792        // output: only a bounded preview (<= the limit plus small framing) is
793        // surfaced, proving the read itself is capped rather than fully buffered.
794        assert!(
795            result.output.len() < RESPONSE_BODY_LIMIT_BYTES + 256,
796            "response read was not bounded: output is {} bytes",
797            result.output.len()
798        );
799    }
800}