1use async_trait::async_trait;
2use futures_util::StreamExt;
3use serde_json::json;
4use std::collections::HashSet;
5use std::path::PathBuf;
6use std::sync::Arc;
7use zeroclaw_api::tool::{Tool, ToolResult};
8use zeroclaw_config::policy::SecurityPolicy;
9use zeroclaw_config::schema::FileUploadBundleConfig;
10
11async fn read_response_bounded(response: reqwest::Response, limit: usize) -> (String, bool) {
23 let mut stream = response.bytes_stream();
24 let mut buf: Vec<u8> = Vec::new();
25 let mut was_truncated = false;
26 while let Some(chunk_result) = stream.next().await {
27 let chunk = match chunk_result {
28 Ok(c) => c,
29 Err(_) => break,
30 };
31 let remaining = limit.saturating_sub(buf.len());
32 if remaining == 0 {
33 was_truncated = true;
36 break;
37 }
38 if chunk.len() > remaining {
39 buf.extend_from_slice(&chunk[..remaining]);
40 was_truncated = true;
41 break;
42 }
43 buf.extend_from_slice(&chunk);
44 }
45 (String::from_utf8_lossy(&buf).into_owned(), was_truncated)
46}
47
48fn truncate_utf8(s: &str, limit: usize) -> &str {
51 if s.len() <= limit {
52 return s;
53 }
54 let mut end = limit;
56 while end > 0 && !s.is_char_boundary(end) {
57 end -= 1;
58 }
59 &s[..end]
60}
61
62pub struct FileUploadBundleTool {
63 security: Arc<SecurityPolicy>,
64 config: FileUploadBundleConfig,
65}
66
67impl FileUploadBundleTool {
68 pub fn new(security: Arc<SecurityPolicy>, config: FileUploadBundleConfig) -> Self {
69 Self { security, config }
70 }
71
72 fn mime_for_filename(name: &str) -> &'static str {
73 let ext = name
74 .rsplit_once('.')
75 .map(|(_, e)| e.to_ascii_lowercase())
76 .unwrap_or_default();
77 match ext.as_str() {
78 "png" | "apng" => "image/png",
80 "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp" => "image/jpeg",
81 "gif" => "image/gif",
82 "webp" => "image/webp",
83 "avif" => "image/avif",
84 "bmp" => "image/bmp",
85 "tiff" | "tif" => "image/tiff",
86 "svg" => "image/svg+xml",
87 "ico" => "image/vnd.microsoft.icon",
88 "heic" | "heif" => "image/heic",
89 "jxl" => "image/jxl",
90
91 "pdf" => "application/pdf",
93 "rtf" => "application/rtf",
94 "epub" => "application/epub+zip",
95 "doc" => "application/msword",
96 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
97 "xls" => "application/vnd.ms-excel",
98 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
99 "ppt" => "application/vnd.ms-powerpoint",
100 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
101 "odt" => "application/vnd.oasis.opendocument.text",
102 "ods" => "application/vnd.oasis.opendocument.spreadsheet",
103 "odp" => "application/vnd.oasis.opendocument.presentation",
104
105 "json" => "application/json",
107 "ndjson" | "jsonl" => "application/x-ndjson",
108 "xml" => "application/xml",
109 "yaml" | "yml" => "application/yaml",
110 "toml" => "application/toml",
111 "csv" => "text/csv",
112 "tsv" => "text/tab-separated-values",
113 "sql" => "application/sql",
114 "ics" => "text/calendar",
115 "vcf" => "text/vcard",
116
117 "txt" | "log" | "ini" | "cfg" | "conf" | "env" => "text/plain",
119 "md" | "markdown" => "text/markdown",
120 "html" | "htm" => "text/html",
121 "css" => "text/css",
122
123 "js" | "mjs" | "cjs" => "application/javascript",
125 "ts" | "tsx" => "application/typescript",
126 "jsx" => "text/jsx",
127 "py" => "text/x-python",
128 "rb" => "text/x-ruby",
129 "go" => "text/x-go",
130 "rs" => "text/x-rust",
131 "java" => "text/x-java",
132 "kt" | "kts" => "text/x-kotlin",
133 "swift" => "text/x-swift",
134 "c" | "h" => "text/x-c",
135 "cc" | "cpp" | "cxx" | "hpp" | "hh" => "text/x-c++",
136 "cs" => "text/x-csharp",
137 "sh" | "bash" | "zsh" => "application/x-sh",
138
139 "zip" => "application/zip",
141 "tar" => "application/x-tar",
142 "gz" | "tgz" => "application/gzip",
143 "bz2" | "tbz2" => "application/x-bzip2",
144 "xz" | "txz" => "application/x-xz",
145 "7z" => "application/x-7z-compressed",
146 "rar" => "application/vnd.rar",
147
148 "mp3" => "audio/mpeg",
150 "wav" => "audio/wav",
151 "ogg" | "oga" | "opus" => "audio/ogg",
152 "flac" => "audio/flac",
153 "aac" => "audio/aac",
154 "m4a" => "audio/mp4",
155 "weba" => "audio/webm",
156 "mid" | "midi" => "audio/midi",
157
158 "mp4" | "m4v" => "video/mp4",
160 "webm" => "video/webm",
161 "mov" | "qt" => "video/quicktime",
162 "mkv" => "video/x-matroska",
163 "avi" => "video/x-msvideo",
164 "mpg" | "mpeg" => "video/mpeg",
165 "3gp" => "video/3gpp",
166 "3g2" => "video/3gpp2",
167
168 "woff" => "font/woff",
170 "woff2" => "font/woff2",
171 "ttf" => "font/ttf",
172 "otf" => "font/otf",
173 "eot" => "application/vnd.ms-fontobject",
174
175 "wasm" => "application/wasm",
177
178 _ => "application/octet-stream",
179 }
180 }
181}
182
183struct PreparedFile {
184 file_name: String,
185 bytes: Vec<u8>,
186 mime: &'static str,
187}
188
189#[async_trait]
190impl Tool for FileUploadBundleTool {
191 fn name(&self) -> &str {
192 "file_upload_bundle"
193 }
194
195 fn description(&self) -> &str {
196 "Upload N local files as a single multipart/form-data request. \
197 All files are sent in one HTTP round-trip; however, transactional \
198 (all-or-nothing) semantics depend on the receiving endpoint. \
199 Use for multi-file deliverables (HTML + CSS + JS, report + figures). \
200 File paths stay on the host; bytes are not loaded into model context. \
201 Returns the HTTP status and a truncated response body."
202 }
203
204 fn parameters_schema(&self) -> serde_json::Value {
205 json!({
206 "type": "object",
207 "properties": {
208 "file_paths": {
209 "type": "array",
210 "items": { "type": "string" },
211 "minItems": 1,
212 "description": "Paths to the files on the agent's filesystem. Relative paths resolve from the workspace."
213 },
214 "entry_file_name": {
215 "type": "string",
216 "description": "Optional filename within file_paths to mark as the bundle's entry (e.g. \"index.html\"). Defaults to the first file. Must match exactly one path's basename."
217 },
218 "project_id": {
219 "type": "string",
220 "description": "Optional project UUID to associate the bundle with on the receiver."
221 }
222 },
223 "required": ["file_paths"]
224 })
225 }
226
227 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
228 let Some(url) = self
229 .config
230 .url
231 .as_deref()
232 .map(str::trim)
233 .filter(|s| !s.is_empty())
234 else {
235 return Ok(ToolResult {
236 success: false,
237 output: String::new(),
238 error: Some(
239 "file_upload_bundle is disabled: [file_upload_bundle].url is not configured"
240 .into(),
241 ),
242 });
243 };
244
245 let method = self.config.method.to_ascii_uppercase();
246 if method != "POST" && method != "PUT" {
247 return Ok(ToolResult {
248 success: false,
249 output: String::new(),
250 error: Some(format!(
251 "Unsupported HTTP method '{method}'. Only POST and PUT are allowed."
252 )),
253 });
254 }
255
256 if !self.security.can_act() {
257 return Ok(ToolResult {
258 success: false,
259 output: String::new(),
260 error: Some("Action blocked: autonomy is read-only".into()),
261 });
262 }
263
264 if self.security.is_rate_limited() {
265 return Ok(ToolResult {
266 success: false,
267 output: String::new(),
268 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
269 });
270 }
271
272 let raw_paths = args
273 .get("file_paths")
274 .and_then(|v| v.as_array())
275 .ok_or_else(|| anyhow::Error::msg("Missing 'file_paths' array parameter"))?;
276
277 if raw_paths.is_empty() {
278 return Ok(ToolResult {
279 success: false,
280 output: String::new(),
281 error: Some("file_paths must not be empty".into()),
282 });
283 }
284 if raw_paths.len() as u64 > self.config.max_files as u64 {
285 return Ok(ToolResult {
286 success: false,
287 output: String::new(),
288 error: Some(format!(
289 "Too many files: {} (limit: {})",
290 raw_paths.len(),
291 self.config.max_files
292 )),
293 });
294 }
295
296 let entry_hint = args
297 .get("entry_file_name")
298 .and_then(|v| v.as_str())
299 .map(str::trim)
300 .filter(|s| !s.is_empty())
301 .map(str::to_string);
302
303 let project_id = args
304 .get("project_id")
305 .and_then(|v| v.as_str())
306 .map(str::trim)
307 .filter(|s| !s.is_empty())
308 .map(str::to_string);
309
310 let mut paths: Vec<String> = Vec::with_capacity(raw_paths.len());
311 for (i, entry) in raw_paths.iter().enumerate() {
312 let p = entry
313 .as_str()
314 .map(str::trim)
315 .filter(|s| !s.is_empty())
316 .ok_or_else(|| {
317 anyhow::Error::msg(format!("file_paths[{i}] must be a non-empty string"))
318 })?;
319 if !self.security.is_path_allowed(p) {
320 return Ok(ToolResult {
321 success: false,
322 output: String::new(),
323 error: Some(format!("Path not allowed by security policy: {p}")),
324 });
325 }
326 paths.push(p.to_string());
327 }
328
329 if !self.security.record_action() {
330 return Ok(ToolResult {
331 success: false,
332 output: String::new(),
333 error: Some("Rate limit exceeded: action budget exhausted".into()),
334 });
335 }
336
337 let mut prepared: Vec<PreparedFile> = Vec::with_capacity(paths.len());
338 let mut seen_names: HashSet<String> = HashSet::with_capacity(paths.len());
339 let mut total_bytes: u64 = 0;
340 for path in &paths {
341 let full_path = self.security.resolve_tool_path(path);
342
343 let resolved_path: PathBuf = match tokio::fs::canonicalize(&full_path).await {
344 Ok(p) => p,
345 Err(e) => {
346 return Ok(ToolResult {
347 success: false,
348 output: String::new(),
349 error: Some(format!("Failed to resolve file path {path}: {e}")),
350 });
351 }
352 };
353
354 if !self.security.is_resolved_path_allowed(&resolved_path) {
355 return Ok(ToolResult {
356 success: false,
357 output: String::new(),
358 error: Some(
359 self.security
360 .resolved_path_violation_message(&resolved_path),
361 ),
362 });
363 }
364
365 let metadata = match tokio::fs::metadata(&resolved_path).await {
366 Ok(m) => m,
367 Err(e) => {
368 return Ok(ToolResult {
369 success: false,
370 output: String::new(),
371 error: Some(format!("Failed to read file metadata for {path}: {e}")),
372 });
373 }
374 };
375
376 if !metadata.is_file() {
377 return Ok(ToolResult {
378 success: false,
379 output: String::new(),
380 error: Some(format!("Not a regular file: {}", resolved_path.display())),
381 });
382 }
383
384 if metadata.len() > self.config.max_file_size_bytes {
387 return Ok(ToolResult {
388 success: false,
389 output: String::new(),
390 error: Some(format!(
391 "File too large: {} is {} bytes (per-file limit: {} bytes)",
392 resolved_path.display(),
393 metadata.len(),
394 self.config.max_file_size_bytes
395 )),
396 });
397 }
398
399 let file_name = resolved_path
400 .file_name()
401 .and_then(|s| s.to_str())
402 .unwrap_or("upload")
403 .to_string();
404 if !seen_names.insert(file_name.clone()) {
405 return Ok(ToolResult {
406 success: false,
407 output: String::new(),
408 error: Some(format!(
409 "Duplicate file name in bundle: {file_name} (filenames must be unique)"
410 )),
411 });
412 }
413
414 let bytes = match tokio::fs::read(&resolved_path).await {
415 Ok(b) => b,
416 Err(e) => {
417 return Ok(ToolResult {
418 success: false,
419 output: String::new(),
420 error: Some(format!("Failed to read {}: {e}", resolved_path.display())),
421 });
422 }
423 };
424
425 let actual_len = bytes.len() as u64;
428 if actual_len > self.config.max_file_size_bytes {
429 return Ok(ToolResult {
430 success: false,
431 output: String::new(),
432 error: Some(format!(
433 "File too large: {} is {} bytes (per-file limit: {} bytes)",
434 resolved_path.display(),
435 actual_len,
436 self.config.max_file_size_bytes
437 )),
438 });
439 }
440
441 total_bytes = total_bytes.saturating_add(actual_len);
442 if total_bytes > self.config.max_total_size_bytes {
443 return Ok(ToolResult {
444 success: false,
445 output: String::new(),
446 error: Some(format!(
447 "Bundle too large: cumulative {} bytes exceeds limit {} bytes",
448 total_bytes, self.config.max_total_size_bytes
449 )),
450 });
451 }
452
453 let mime = Self::mime_for_filename(&file_name);
454 prepared.push(PreparedFile {
455 file_name,
456 bytes,
457 mime,
458 });
459 }
460
461 if let Some(name) = &entry_hint
462 && !prepared.iter().any(|f| &f.file_name == name)
463 {
464 return Ok(ToolResult {
465 success: false,
466 output: String::new(),
467 error: Some(format!(
468 "entry_file_name '{name}' does not match any file in file_paths"
469 )),
470 });
471 }
472
473 let mut form = reqwest::multipart::Form::new();
474 for file in &prepared {
475 let part = match reqwest::multipart::Part::bytes(file.bytes.clone())
476 .file_name(file.file_name.clone())
477 .mime_str(file.mime)
478 {
479 Ok(p) => p,
480 Err(e) => {
481 return Ok(ToolResult {
482 success: false,
483 output: String::new(),
484 error: Some(format!("Failed to build multipart part: {e}")),
485 });
486 }
487 };
488 form = form.part(self.config.field_name.clone(), part);
489 }
490 if let Some(name) = entry_hint {
491 form = form.text("entry_file_name", name);
492 }
493 if let Some(pid) = project_id {
494 form = form.text("project_id", pid);
495 }
496
497 let client = zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
498 "tool.file_upload_bundle",
499 self.config.timeout_secs,
500 10,
501 );
502
503 let mut request = if method == "PUT" {
504 client.put(url)
505 } else {
506 client.post(url)
507 };
508
509 for (k, v) in &self.config.headers {
510 request = request.header(k.as_str(), v.as_str());
511 }
512
513 let response = match request.multipart(form).send().await {
514 Ok(r) => r,
515 Err(e) => {
516 return Ok(ToolResult {
517 success: false,
518 output: String::new(),
519 error: Some(format!("Bundle upload request failed: {e}")),
520 });
521 }
522 };
523
524 let status = response.status();
525 let body_limit = self.config.max_response_body_bytes;
534 let (raw_body, was_truncated) = read_response_bounded(response, body_limit).await;
535 let truncated = if was_truncated {
536 let safe = truncate_utf8(&raw_body, body_limit);
537 format!("{safe}... [truncated]")
538 } else {
539 raw_body
540 };
541
542 let file_count = prepared.len();
543 if status.is_success() {
544 Ok(ToolResult {
545 success: true,
546 output: format!(
547 "Uploaded bundle of {file_count} files ({status}). Response: {truncated}"
548 ),
549 error: None,
550 })
551 } else {
552 Ok(ToolResult {
553 success: false,
554 output: truncated,
555 error: Some(format!(
556 "Upload endpoint returned status {status} for bundle of {file_count} files"
557 )),
558 })
559 }
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use std::collections::HashMap;
567 use std::fs;
568 use std::path::PathBuf;
569 use tempfile::TempDir;
570 use wiremock::matchers::{header, method, path};
571 use wiremock::{Mock, MockServer, ResponseTemplate};
572 use zeroclaw_config::autonomy::AutonomyLevel;
573
574 fn test_security(workspace: PathBuf, level: AutonomyLevel) -> Arc<SecurityPolicy> {
575 Arc::new(SecurityPolicy {
576 autonomy: level,
577 max_actions_per_hour: 100,
578 workspace_dir: workspace,
579 ..SecurityPolicy::default()
580 })
581 }
582
583 fn cfg(url: Option<String>) -> FileUploadBundleConfig {
584 FileUploadBundleConfig {
585 url,
586 ..FileUploadBundleConfig::default()
587 }
588 }
589
590 #[test]
591 fn tool_name_and_description() {
592 let tmp = TempDir::new().unwrap();
593 let tool = FileUploadBundleTool::new(
594 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
595 cfg(Some("https://example.com/upload_bundle".into())),
596 );
597 assert_eq!(tool.name(), "file_upload_bundle");
598 assert!(!tool.description().is_empty());
599 }
600
601 #[test]
602 fn schema_requires_file_paths_array() {
603 let tmp = TempDir::new().unwrap();
604 let tool = FileUploadBundleTool::new(
605 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
606 cfg(Some("https://example.com/upload_bundle".into())),
607 );
608 let schema = tool.parameters_schema();
609 assert_eq!(schema["type"], "object");
610 let required = schema["required"].as_array().unwrap();
611 assert!(required.contains(&serde_json::Value::String("file_paths".into())));
612 assert_eq!(schema["properties"]["file_paths"]["type"], "array");
613 }
614
615 #[tokio::test]
616 async fn execute_fails_when_url_unset() {
617 let tmp = TempDir::new().unwrap();
618 let file = tmp.path().join("a.txt");
619 fs::write(&file, b"a").unwrap();
620
621 let tool = FileUploadBundleTool::new(
622 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
623 cfg(None),
624 );
625
626 let result = tool
627 .execute(json!({ "file_paths": ["a.txt"] }))
628 .await
629 .unwrap();
630 assert!(!result.success);
631 assert!(result.error.unwrap().contains("disabled"));
632 }
633
634 #[tokio::test]
635 async fn execute_blocks_readonly_autonomy() {
636 let tmp = TempDir::new().unwrap();
637 let file = tmp.path().join("a.txt");
638 fs::write(&file, b"a").unwrap();
639
640 let tool = FileUploadBundleTool::new(
641 test_security(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly),
642 cfg(Some("https://example.com/upload_bundle".into())),
643 );
644
645 let result = tool
646 .execute(json!({ "file_paths": ["a.txt"] }))
647 .await
648 .unwrap();
649 assert!(!result.success);
650 assert!(result.error.unwrap().contains("read-only"));
651 }
652
653 #[tokio::test]
654 async fn execute_rejects_empty_file_paths() {
655 let tmp = TempDir::new().unwrap();
656 let tool = FileUploadBundleTool::new(
657 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
658 cfg(Some("https://example.com/upload_bundle".into())),
659 );
660
661 let result = tool.execute(json!({ "file_paths": [] })).await.unwrap();
662 assert!(!result.success);
663 assert!(result.error.unwrap().contains("must not be empty"));
664 }
665
666 #[tokio::test]
667 async fn execute_rejects_too_many_files() {
668 let tmp = TempDir::new().unwrap();
669 let mut config = cfg(Some("https://example.com/upload_bundle".into()));
670 config.max_files = 2;
671 let tool = FileUploadBundleTool::new(
672 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
673 config,
674 );
675
676 let result = tool
677 .execute(json!({ "file_paths": ["a.txt", "b.txt", "c.txt"] }))
678 .await
679 .unwrap();
680 assert!(!result.success);
681 assert!(result.error.unwrap().contains("Too many files"));
682 }
683
684 #[tokio::test]
685 async fn execute_rejects_per_file_over_size_cap() {
686 let tmp = TempDir::new().unwrap();
687 fs::write(tmp.path().join("ok.bin"), vec![0u8; 100]).unwrap();
688 fs::write(tmp.path().join("big.bin"), vec![0u8; 2048]).unwrap();
689
690 let mut config = cfg(Some("https://example.com/upload_bundle".into()));
691 config.max_file_size_bytes = 1024;
692
693 let tool = FileUploadBundleTool::new(
694 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
695 config,
696 );
697
698 let result = tool
699 .execute(json!({ "file_paths": ["ok.bin", "big.bin"] }))
700 .await
701 .unwrap();
702 assert!(!result.success);
703 assert!(result.error.unwrap().contains("too large"));
704 }
705
706 #[tokio::test]
707 async fn execute_rejects_cumulative_over_total_cap() {
708 let tmp = TempDir::new().unwrap();
709 fs::write(tmp.path().join("a.bin"), vec![0u8; 800]).unwrap();
710 fs::write(tmp.path().join("b.bin"), vec![0u8; 800]).unwrap();
711
712 let mut config = cfg(Some("https://example.com/upload_bundle".into()));
713 config.max_file_size_bytes = 1024;
714 config.max_total_size_bytes = 1024;
715
716 let tool = FileUploadBundleTool::new(
717 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
718 config,
719 );
720
721 let result = tool
722 .execute(json!({ "file_paths": ["a.bin", "b.bin"] }))
723 .await
724 .unwrap();
725 assert!(!result.success);
726 assert!(result.error.unwrap().contains("Bundle too large"));
727 }
728
729 #[tokio::test]
730 async fn execute_rejects_duplicate_filenames() {
731 let tmp = TempDir::new().unwrap();
732 let sub = tmp.path().join("sub");
733 fs::create_dir(&sub).unwrap();
734 fs::write(tmp.path().join("index.html"), b"<a/>").unwrap();
735 fs::write(sub.join("index.html"), b"<b/>").unwrap();
736
737 let tool = FileUploadBundleTool::new(
738 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
739 cfg(Some("https://example.com/upload_bundle".into())),
740 );
741
742 let result = tool
743 .execute(json!({ "file_paths": ["index.html", "sub/index.html"] }))
744 .await
745 .unwrap();
746 assert!(!result.success);
747 assert!(result.error.unwrap().contains("Duplicate file name"));
748 }
749
750 #[tokio::test]
751 async fn execute_rejects_entry_not_in_files() {
752 let tmp = TempDir::new().unwrap();
753 fs::write(tmp.path().join("a.html"), b"<a/>").unwrap();
754
755 let tool = FileUploadBundleTool::new(
756 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
757 cfg(Some("https://example.com/upload_bundle".into())),
758 );
759
760 let result = tool
761 .execute(json!({
762 "file_paths": ["a.html"],
763 "entry_file_name": "missing.html"
764 }))
765 .await
766 .unwrap();
767 assert!(!result.success);
768 assert!(result.error.unwrap().contains("does not match any file"));
769 }
770
771 #[tokio::test]
772 async fn execute_rejects_path_outside_workspace() {
773 let workspace = TempDir::new().unwrap();
774 let outside = TempDir::new().unwrap();
775 let file = outside.path().join("secret.txt");
776 fs::write(&file, b"nope").unwrap();
777
778 let tool = FileUploadBundleTool::new(
779 test_security(workspace.path().to_path_buf(), AutonomyLevel::Full),
780 cfg(Some("https://example.com/upload_bundle".into())),
781 );
782
783 let result = tool
784 .execute(json!({ "file_paths": [file.to_string_lossy()] }))
785 .await
786 .unwrap();
787 assert!(!result.success);
788 }
789
790 #[tokio::test]
791 async fn execute_uploads_bundle_with_multipart_parts_and_headers() {
792 let server = MockServer::start().await;
793 let tmp = TempDir::new().unwrap();
794 fs::write(tmp.path().join("index.html"), b"<html></html>").unwrap();
795 fs::write(tmp.path().join("styles.css"), b"body{}").unwrap();
796 fs::write(tmp.path().join("app.js"), b"console.log(1)").unwrap();
797
798 Mock::given(method("POST"))
799 .and(path("/upload_bundle"))
800 .and(header("X-Auth", "Bearer xyz"))
801 .respond_with(ResponseTemplate::new(201).set_body_string(
802 r#"{"bundle_id":"abc","entry_file_id":"def","files":[{"file_name":"index.html"},{"file_name":"styles.css"},{"file_name":"app.js"}]}"#,
803 ))
804 .expect(1)
805 .mount(&server)
806 .await;
807
808 let mut headers = HashMap::new();
809 headers.insert("X-Auth".into(), "Bearer xyz".into());
810 let config = FileUploadBundleConfig {
811 url: Some(format!("{}/upload_bundle", server.uri())),
812 headers,
813 ..FileUploadBundleConfig::default()
814 };
815
816 let tool = FileUploadBundleTool::new(
817 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
818 config,
819 );
820
821 let result = tool
822 .execute(json!({
823 "file_paths": ["index.html", "styles.css", "app.js"],
824 "entry_file_name": "index.html",
825 "project_id": "proj-42"
826 }))
827 .await
828 .unwrap();
829
830 assert!(result.success, "expected success, got {result:?}");
831 assert!(result.output.contains("3 files"));
832 assert!(result.output.contains("abc"));
833
834 let recorded = server
837 .received_requests()
838 .await
839 .expect("wiremock should have captured the request");
840 assert_eq!(recorded.len(), 1);
841 let body = String::from_utf8_lossy(&recorded[0].body);
842
843 for expected_name in ["index.html", "styles.css", "app.js"] {
845 assert!(
846 body.contains(&format!("filename=\"{expected_name}\"")),
847 "multipart body should contain part for {expected_name}"
848 );
849 }
850 assert!(body.contains("<html></html>"), "index.html content missing");
852 assert!(body.contains("body{}"), "styles.css content missing");
853 assert!(body.contains("console.log(1)"), "app.js content missing");
854
855 assert!(
857 body.contains("entry_file_name") && body.contains("index.html"),
858 "entry_file_name text field missing"
859 );
860 assert!(
861 body.contains("project_id") && body.contains("proj-42"),
862 "project_id text field missing"
863 );
864 }
865
866 #[tokio::test]
867 async fn execute_reports_non_2xx_response() {
868 let server = MockServer::start().await;
869 let tmp = TempDir::new().unwrap();
870 fs::write(tmp.path().join("a.txt"), b"a").unwrap();
871
872 Mock::given(method("POST"))
873 .and(path("/upload_bundle"))
874 .respond_with(ResponseTemplate::new(422).set_body_string("bundle_too_large"))
875 .expect(1)
876 .mount(&server)
877 .await;
878
879 let config = FileUploadBundleConfig {
880 url: Some(format!("{}/upload_bundle", server.uri())),
881 ..FileUploadBundleConfig::default()
882 };
883
884 let tool = FileUploadBundleTool::new(
885 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
886 config,
887 );
888
889 let result = tool
890 .execute(json!({ "file_paths": ["a.txt"] }))
891 .await
892 .unwrap();
893 assert!(!result.success);
894 let err = result.error.unwrap();
895 assert!(err.contains("422"), "unexpected error: {err}");
896 }
897
898 #[test]
899 fn mime_table_covers_common_bundle_extensions() {
900 let cases = [
901 ("photo.png", "image/png"),
903 ("snap.JPG", "image/jpeg"),
904 ("anim.gif", "image/gif"),
905 ("hero.webp", "image/webp"),
906 ("modern.avif", "image/avif"),
907 ("favicon.ico", "image/vnd.microsoft.icon"),
908 ("vector.svg", "image/svg+xml"),
909 ("phone.heic", "image/heic"),
910 ("paper.PDF", "application/pdf"),
912 (
913 "brief.docx",
914 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
915 ),
916 (
917 "budget.xlsx",
918 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
919 ),
920 (
921 "slides.pptx",
922 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
923 ),
924 ("notes.odt", "application/vnd.oasis.opendocument.text"),
925 ("book.epub", "application/epub+zip"),
926 ("data.json", "application/json"),
928 ("stream.ndjson", "application/x-ndjson"),
929 ("conf.yaml", "application/yaml"),
930 ("Cargo.toml", "application/toml"),
931 ("rows.tsv", "text/tab-separated-values"),
932 ("schema.sql", "application/sql"),
933 ("invite.ics", "text/calendar"),
934 ("README.md", "text/markdown"),
936 ("index.html", "text/html"),
937 ("style.css", "text/css"),
938 ("setup.env", "text/plain"),
939 ("app.js", "application/javascript"),
941 ("api.ts", "application/typescript"),
942 ("Page.tsx", "application/typescript"),
943 ("main.py", "text/x-python"),
944 ("lib.rs", "text/x-rust"),
945 ("Main.kt", "text/x-kotlin"),
946 ("run.sh", "application/x-sh"),
947 ("app.cpp", "text/x-c++"),
948 ("src.zip", "application/zip"),
950 ("logs.tar.gz", "application/gzip"),
951 ("dump.bz2", "application/x-bzip2"),
952 ("pack.7z", "application/x-7z-compressed"),
953 ("song.mp3", "audio/mpeg"),
955 ("voice.flac", "audio/flac"),
956 ("voice.m4a", "audio/mp4"),
957 ("clip.mp4", "video/mp4"),
959 ("rec.mkv", "video/x-matroska"),
960 ("legacy.avi", "video/x-msvideo"),
961 ("font.woff2", "font/woff2"),
963 ("font.ttf", "font/ttf"),
964 ("module.wasm", "application/wasm"),
966 ("noext", "application/octet-stream"),
968 ("weird.qq", "application/octet-stream"),
969 ];
970 for (name, expected) in cases {
971 assert_eq!(
972 FileUploadBundleTool::mime_for_filename(name),
973 expected,
974 "{name} should map to {expected}"
975 );
976 }
977 }
978
979 #[test]
982 fn truncate_utf8_within_limit() {
983 assert_eq!(truncate_utf8("hello", 10), "hello");
984 }
985
986 #[test]
987 fn truncate_utf8_exact_boundary() {
988 assert_eq!(truncate_utf8("hello", 5), "hello");
989 }
990
991 #[test]
992 fn truncate_utf8_ascii() {
993 assert_eq!(truncate_utf8("hello world", 5), "hello");
994 }
995
996 #[test]
997 fn truncate_utf8_respects_char_boundary() {
998 let s = "é";
1000 assert_eq!(s.len(), 2);
1001 assert_eq!(truncate_utf8(s, 1), "");
1002 assert_eq!(truncate_utf8(s, 2), "é");
1003
1004 let s2 = "aé";
1006 assert_eq!(truncate_utf8(s2, 2), "a");
1007 assert_eq!(truncate_utf8(s2, 3), "aé");
1008 }
1009
1010 #[test]
1011 fn truncate_utf8_multibyte_emoji() {
1012 let s = "😀";
1014 assert_eq!(s.len(), 4);
1015 assert_eq!(truncate_utf8(s, 1), "");
1016 assert_eq!(truncate_utf8(s, 2), "");
1017 assert_eq!(truncate_utf8(s, 3), "");
1018 assert_eq!(truncate_utf8(s, 4), "😀");
1019 }
1020
1021 #[test]
1022 fn truncate_utf8_empty() {
1023 assert_eq!(truncate_utf8("", 0), "");
1024 assert_eq!(truncate_utf8("", 10), "");
1025 }
1026
1027 #[test]
1030 fn description_does_not_claim_atomicity() {
1031 let tmp = TempDir::new().unwrap();
1032 let tool = FileUploadBundleTool::new(
1033 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
1034 cfg(Some("https://example.com/upload_bundle".into())),
1035 );
1036 let desc = tool.description();
1037 assert!(
1039 !desc.contains("All files land or none do"),
1040 "description should not claim atomic semantics"
1041 );
1042 assert!(
1043 !desc.contains("atomic"),
1044 "description should not use the word 'atomic'"
1045 );
1046 }
1047
1048 #[tokio::test]
1051 async fn execute_truncates_over_limit_response_with_multibyte_boundary() {
1052 let body_limit: usize = 64;
1055
1056 let padding = "A".repeat(63);
1067 let oversized_body = format!("{padding}é{}", "B".repeat(200));
1068 assert!(
1069 oversized_body.len() > body_limit,
1070 "test body must exceed limit"
1071 );
1072
1073 let server = MockServer::start().await;
1074 let tmp = TempDir::new().unwrap();
1075 fs::write(tmp.path().join("payload.txt"), b"data").unwrap();
1076
1077 Mock::given(method("POST"))
1078 .and(path("/upload_bundle"))
1079 .respond_with(ResponseTemplate::new(200).set_body_string(oversized_body.clone()))
1080 .expect(1)
1081 .mount(&server)
1082 .await;
1083
1084 let config = FileUploadBundleConfig {
1085 url: Some(format!("{}/upload_bundle", server.uri())),
1086 max_response_body_bytes: body_limit,
1087 ..FileUploadBundleConfig::default()
1088 };
1089
1090 let tool = FileUploadBundleTool::new(
1091 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
1092 config,
1093 );
1094
1095 let result = tool
1096 .execute(json!({ "file_paths": ["payload.txt"] }))
1097 .await
1098 .expect("execute must not panic on multibyte boundary truncation");
1099
1100 assert!(result.success, "expected success, got {result:?}");
1103 assert!(
1104 result.output.contains("[truncated]"),
1105 "output should contain [truncated] marker, got: {}",
1106 result.output
1107 );
1108
1109 let response_part = result
1115 .output
1116 .split("Response: ")
1117 .nth(1)
1118 .expect("output should contain 'Response: ' prefix");
1119 let before_marker = response_part
1120 .strip_suffix("... [truncated]")
1121 .expect("response part should end with '... [truncated]'");
1122 assert!(
1123 before_marker.len() <= body_limit,
1124 "truncated body ({} bytes) should not exceed limit ({} bytes)",
1125 before_marker.len(),
1126 body_limit,
1127 );
1128 }
1129
1130 #[tokio::test]
1133 async fn execute_marks_over_limit_ascii_response_as_truncated() {
1134 let body_limit: usize = 64;
1142
1143 let oversized_body = "A".repeat(200);
1147 assert!(
1148 oversized_body.len() > body_limit,
1149 "test body must exceed limit"
1150 );
1151
1152 let server = MockServer::start().await;
1153 let tmp = TempDir::new().unwrap();
1154 fs::write(tmp.path().join("payload.txt"), b"data").unwrap();
1155
1156 Mock::given(method("POST"))
1157 .and(path("/upload_bundle"))
1158 .respond_with(ResponseTemplate::new(200).set_body_string(oversized_body))
1159 .expect(1)
1160 .mount(&server)
1161 .await;
1162
1163 let config = FileUploadBundleConfig {
1164 url: Some(format!("{}/upload_bundle", server.uri())),
1165 max_response_body_bytes: body_limit,
1166 ..FileUploadBundleConfig::default()
1167 };
1168
1169 let tool = FileUploadBundleTool::new(
1170 test_security(tmp.path().to_path_buf(), AutonomyLevel::Full),
1171 config,
1172 );
1173
1174 let result = tool
1175 .execute(json!({ "file_paths": ["payload.txt"] }))
1176 .await
1177 .expect("execute must not panic on ASCII truncation");
1178
1179 assert!(result.success, "expected success, got {result:?}");
1180 assert!(
1181 result.output.contains("[truncated]"),
1182 "over-limit ASCII response must carry the [truncated] marker, got: {}",
1183 result.output
1184 );
1185
1186 let response_part = result
1189 .output
1190 .split("Response: ")
1191 .nth(1)
1192 .expect("output should contain 'Response: ' prefix");
1193 let before_marker = response_part
1194 .strip_suffix("... [truncated]")
1195 .expect("response part should end with '... [truncated]'");
1196 assert_eq!(
1197 before_marker.len(),
1198 body_limit,
1199 "clipped ASCII body should be exactly the limit"
1200 );
1201 assert!(
1202 before_marker.bytes().all(|b| b == b'A'),
1203 "clipped body should be the leading 'A' run, got: {before_marker}"
1204 );
1205 }
1206}