Skip to main content

zeroclaw_tools/
file_upload_bundle.rs

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
11/// Read at most `limit` bytes from a response body via streaming,
12/// then lossily convert to UTF-8. This avoids loading an unbounded
13/// response into memory.
14///
15/// Returns the captured (lossy-UTF-8) body together with a
16/// `was_truncated` flag that is `true` when reading stopped because the
17/// byte limit was reached while more body remained. Callers must rely on
18/// this flag rather than the captured length: a clean ASCII or otherwise
19/// valid-UTF-8 body that overruns the limit is clipped to exactly `limit`
20/// bytes, so its length alone is indistinguishable from a complete
21/// response that happens to be exactly `limit` bytes long.
22async 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            // Buffer already full and another chunk arrived: the body
34            // continues past the limit.
35            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
48/// Truncate a string to at most `limit` bytes without splitting a
49/// multi-byte UTF-8 character.
50fn truncate_utf8(s: &str, limit: usize) -> &str {
51    if s.len() <= limit {
52        return s;
53    }
54    // Walk backwards from the limit to find a valid char boundary.
55    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            // Images
79            "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            // Documents
92            "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            // Structured data
106            "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            // Text + markup
118            "txt" | "log" | "ini" | "cfg" | "conf" | "env" => "text/plain",
119            "md" | "markdown" => "text/markdown",
120            "html" | "htm" => "text/html",
121            "css" => "text/css",
122
123            // Source code
124            "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            // Archives
140            "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            // Audio
149            "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            // Video
159            "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            // Fonts
169            "woff" => "font/woff",
170            "woff2" => "font/woff2",
171            "ttf" => "font/ttf",
172            "otf" => "font/otf",
173            "eot" => "application/vnd.ms-fontobject",
174
175            // Web binary
176            "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            // Pre-check with metadata (cheap); the authoritative check
385            // happens after the actual read to close the TOCTOU gap.
386            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            // Authoritative size checks on the actual bytes read, closing
426            // the TOCTOU window between metadata and read.
427            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        // Bounded streaming read — never buffers more than the limit.
526        // `read_response_bounded` returns lossy UTF-8 so the result is
527        // always a valid String, and `truncate_utf8` never splits a
528        // multi-byte char. We gate the truncation marker on the reader's
529        // `was_truncated` flag rather than the captured length, because a
530        // clean ASCII/valid-UTF-8 body that overruns the limit is clipped
531        // to exactly `body_limit` bytes and would otherwise be reported as
532        // complete.
533        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        // Inspect the raw multipart body to verify all file parts and
835        // optional text fields are present.
836        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        // Each file part must appear with its Content-Disposition filename.
844        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        // File content must be present in the body.
851        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        // Text fields: entry_file_name and project_id.
856        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            // images
902            ("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            // documents
911            ("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
927            ("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            // text + markup
935            ("README.md", "text/markdown"),
936            ("index.html", "text/html"),
937            ("style.css", "text/css"),
938            ("setup.env", "text/plain"),
939            // source code
940            ("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            // archives
949            ("src.zip", "application/zip"),
950            ("logs.tar.gz", "application/gzip"),
951            ("dump.bz2", "application/x-bzip2"),
952            ("pack.7z", "application/x-7z-compressed"),
953            // audio
954            ("song.mp3", "audio/mpeg"),
955            ("voice.flac", "audio/flac"),
956            ("voice.m4a", "audio/mp4"),
957            // video
958            ("clip.mp4", "video/mp4"),
959            ("rec.mkv", "video/x-matroska"),
960            ("legacy.avi", "video/x-msvideo"),
961            // fonts
962            ("font.woff2", "font/woff2"),
963            ("font.ttf", "font/ttf"),
964            // web binary
965            ("module.wasm", "application/wasm"),
966            // fallback
967            ("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    // ── truncate_utf8 ───────────────────────────────────────────
980
981    #[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        // "é" is 2 bytes (0xC3 0xA9). Cutting at byte 1 must back up.
999        let s = "é";
1000        assert_eq!(s.len(), 2);
1001        assert_eq!(truncate_utf8(s, 1), "");
1002        assert_eq!(truncate_utf8(s, 2), "é");
1003
1004        // "aé" = 3 bytes. Cutting at byte 2 must not split the é.
1005        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        // "😀" is 4 bytes. Cutting at 1, 2, or 3 must produce "".
1013        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    // ── description wording ─────────────────────────────────────
1028
1029    #[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        // Must not promise all-or-nothing semantics.
1038        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    // ── bounded response with multibyte boundary ────────────────
1049
1050    #[tokio::test]
1051    async fn execute_truncates_over_limit_response_with_multibyte_boundary() {
1052        // Use a small response-body limit so we can craft a tight test
1053        // without allocating megabytes.
1054        let body_limit: usize = 64;
1055
1056        // Build a response body that exceeds the limit and places a
1057        // multi-byte UTF-8 character ("é" = 2 bytes, 0xC3 0xA9) right
1058        // at the cut point so read_response_bounded slices mid-character.
1059        //
1060        // Layout: 63 bytes of ASCII padding + "é" (2 bytes) + more ASCII.
1061        // read_response_bounded reads the first 64 raw bytes, which cuts
1062        // the "é" after its first byte. from_utf8_lossy replaces the
1063        // dangling 0xC3 with U+FFFD (3 bytes), making the String 66
1064        // bytes — exceeding the 64-byte limit and triggering the
1065        // truncate_utf8 + "[truncated]" path.
1066        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        // The tool should succeed (HTTP 200) and the output must carry
1101        // the truncation marker.
1102        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        // The output (minus the "Uploaded bundle…" prefix and the
1110        // "... [truncated]" suffix) must be valid UTF-8 and must not
1111        // exceed the body limit.  We don't assert the exact byte count
1112        // because the prefix is implementation detail, but we verify the
1113        // response portion is bounded.
1114        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    // ── bounded response with plain ASCII overrun ───────────────
1131
1132    #[tokio::test]
1133    async fn execute_marks_over_limit_ascii_response_as_truncated() {
1134        // Regression: a clean ASCII (valid-UTF-8) response that overruns
1135        // the limit is clipped by read_response_bounded to exactly
1136        // `body_limit` bytes. The earlier `raw_body.len() > body_limit`
1137        // gate was then false, so the tool returned a clipped body with no
1138        // "[truncated]" marker — hiding from the agent that the receiver
1139        // body was cut. The reader's `was_truncated` flag must drive the
1140        // marker instead.
1141        let body_limit: usize = 64;
1142
1143        // Pure ASCII, no multi-byte char near the cut point, so
1144        // from_utf8_lossy does not expand the captured bytes past the
1145        // limit (which is what masked the bug in the multibyte case).
1146        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        // The captured body must be bounded to exactly the limit (64 'A's)
1187        // with the marker following it.
1188        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}