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 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 "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 "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 "json" => "application/json",
63 "xml" => "application/xml",
64 "yaml" | "yml" => "application/yaml",
65 "toml" => "application/toml",
66 "sql" => "application/sql",
67 "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 "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 "mp3" => "audio/mpeg",
89 "wav" => "audio/wav",
90 "ogg" | "oga" | "opus" => "audio/ogg",
91 "flac" => "audio/flac",
92 "m4a" | "mp4" => "video/mp4",
94 "webm" => "video/webm",
95 "mov" => "video/quicktime",
96 "mkv" => "video/x-matroska",
97 "avi" => "video/x-msvideo",
98 "woff" => "font/woff",
100 "woff2" => "font/woff2",
101 "ttf" => "font/ttf",
102 "otf" => "font/otf",
103 _ => "application/octet-stream",
104 }
105 }
106
107 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 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 fn truncate_response_body(body: &str) -> String {
138 if body.len() <= RESPONSE_BODY_LIMIT_BYTES {
139 return body.to_string();
140 }
141 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 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 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 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 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 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 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 let body = "€".repeat(2000); 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 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 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 let huge_body = "€".repeat(1_000_000); 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 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}