1use crate::environment::{self, Environment, ExecResult, FileEntry, GrepMatch};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::RwLock;
13
14const MAX_EXEC_OUTPUT_BYTES: usize = 1024 * 1024;
17
18const MAX_GREP_FILE_BYTES: u64 = 16 * 1024 * 1024;
21
22async fn read_capped<R>(mut reader: R) -> std::io::Result<(Vec<u8>, bool)>
27where
28 R: tokio::io::AsyncRead + Unpin,
29{
30 use tokio::io::AsyncReadExt;
31
32 let mut captured = Vec::new();
33 let mut chunk = [0u8; 8192];
34 let mut truncated = false;
35 loop {
36 let read = reader.read(&mut chunk).await?;
37 if read == 0 {
38 break;
39 }
40 if captured.len() < MAX_EXEC_OUTPUT_BYTES {
41 let remaining = MAX_EXEC_OUTPUT_BYTES - captured.len();
42 let take = remaining.min(read);
43 captured.extend_from_slice(&chunk[..take]);
44 if take < read {
45 truncated = true;
46 }
47 } else {
48 truncated = true;
49 }
50 }
51 Ok((captured, truncated))
52}
53
54fn render_capped_output(bytes: &[u8], truncated: bool) -> String {
57 let mut text = String::from_utf8_lossy(bytes).into_owned();
58 if truncated {
59 text.push_str("\n[output truncated: exceeded 1 MiB cap]");
60 }
61 text
62}
63
64pub struct LocalFileSystem {
66 root: PathBuf,
67}
68
69impl LocalFileSystem {
70 #[must_use]
71 pub fn new(root: impl Into<PathBuf>) -> Self {
72 Self { root: root.into() }
73 }
74
75 fn resolve(&self, path: &str) -> PathBuf {
76 let joined = if Path::new(path).is_absolute() {
77 PathBuf::from(path)
78 } else {
79 self.root.join(path)
80 };
81 environment::normalize_path_buf(&joined)
82 }
83}
84
85#[async_trait]
86impl Environment for LocalFileSystem {
87 async fn read_file(&self, path: &str) -> Result<String> {
88 let path = self.resolve(path);
89 tokio::fs::read_to_string(&path)
90 .await
91 .with_context(|| format!("Failed to read file: {}", path.display()))
92 }
93
94 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
95 let path = self.resolve(path);
96 tokio::fs::read(&path)
97 .await
98 .with_context(|| format!("Failed to read file: {}", path.display()))
99 }
100
101 async fn write_file(&self, path: &str, content: &str) -> Result<()> {
102 let path = self.resolve(path);
103 if let Some(parent) = path.parent() {
104 tokio::fs::create_dir_all(parent).await?;
105 }
106 tokio::fs::write(&path, content)
107 .await
108 .with_context(|| format!("Failed to write file: {}", path.display()))
109 }
110
111 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
112 let path = self.resolve(path);
113 if let Some(parent) = path.parent() {
114 tokio::fs::create_dir_all(parent).await?;
115 }
116 tokio::fs::write(&path, content)
117 .await
118 .with_context(|| format!("Failed to write file: {}", path.display()))
119 }
120
121 async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>> {
122 let path = self.resolve(path);
123 let mut entries = Vec::new();
124 let mut dir = tokio::fs::read_dir(&path)
125 .await
126 .with_context(|| format!("Failed to read directory: {}", path.display()))?;
127
128 while let Some(entry) = dir.next_entry().await? {
129 let metadata = entry.metadata().await?;
130 entries.push(FileEntry {
131 name: entry.file_name().to_string_lossy().to_string(),
132 path: entry.path().to_string_lossy().to_string(),
133 is_dir: metadata.is_dir(),
134 size: if metadata.is_file() {
135 Some(metadata.len())
136 } else {
137 None
138 },
139 });
140 }
141
142 Ok(entries)
143 }
144
145 async fn exists(&self, path: &str) -> Result<bool> {
146 let path = self.resolve(path);
147 Ok(tokio::fs::try_exists(&path).await.unwrap_or(false))
148 }
149
150 async fn is_dir(&self, path: &str) -> Result<bool> {
151 let path = self.resolve(path);
152 Ok(tokio::fs::metadata(&path).await.is_ok_and(|m| m.is_dir()))
153 }
154
155 async fn is_file(&self, path: &str) -> Result<bool> {
156 let path = self.resolve(path);
157 Ok(tokio::fs::metadata(&path).await.is_ok_and(|m| m.is_file()))
158 }
159
160 async fn create_dir(&self, path: &str) -> Result<()> {
161 let path = self.resolve(path);
162 tokio::fs::create_dir_all(&path)
163 .await
164 .with_context(|| format!("Failed to create directory: {}", path.display()))
165 }
166
167 async fn delete_file(&self, path: &str) -> Result<()> {
168 let path = self.resolve(path);
169 tokio::fs::remove_file(&path)
170 .await
171 .with_context(|| format!("Failed to delete file: {}", path.display()))
172 }
173
174 async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()> {
175 let path = self.resolve(path);
176 if recursive {
177 tokio::fs::remove_dir_all(&path)
178 .await
179 .with_context(|| format!("Failed to delete directory: {}", path.display()))
180 } else {
181 tokio::fs::remove_dir(&path)
182 .await
183 .with_context(|| format!("Failed to delete directory: {}", path.display()))
184 }
185 }
186
187 async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>> {
188 let path = self.resolve(path);
189 let regex = regex::Regex::new(pattern).context("Invalid regex pattern")?;
190 let mut matches = Vec::new();
191
192 if path.is_file() {
193 self.grep_file(&path, ®ex, &mut matches).await?;
194 } else if path.is_dir() {
195 self.grep_dir(&path, ®ex, recursive, &mut matches)
196 .await?;
197 }
198
199 Ok(matches)
200 }
201
202 async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
203 let pattern_path = self.resolve(pattern);
204 let pattern_str = pattern_path.to_string_lossy();
205
206 let paths: Vec<String> = glob::glob(&pattern_str)
207 .context("Invalid glob pattern")?
208 .filter_map(std::result::Result::ok)
209 .map(|p| p.to_string_lossy().to_string())
210 .collect();
211
212 Ok(paths)
213 }
214
215 async fn exec(&self, command: &str, timeout_ms: Option<u64>) -> Result<ExecResult> {
216 use std::process::Stdio;
217 use tokio::process::Command;
218
219 let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(120_000));
220
221 let mut child = Command::new("sh")
226 .arg("-c")
227 .arg(command)
228 .current_dir(&self.root)
229 .stdout(Stdio::piped())
230 .stderr(Stdio::piped())
231 .kill_on_drop(true)
232 .spawn()
233 .context("Failed to execute command")?;
234
235 let stdout = child.stdout.take().context("missing stdout pipe")?;
236 let stderr = child.stderr.take().context("missing stderr pipe")?;
237
238 let run = async {
242 let (out, err, status) =
243 tokio::join!(read_capped(stdout), read_capped(stderr), child.wait());
244 anyhow::Ok((out?, err?, status?))
245 };
246
247 let outcome = tokio::time::timeout(timeout, Box::pin(run)).await;
250
251 match outcome {
252 Ok(joined) => {
253 let ((stdout_bytes, stdout_truncated), (stderr_bytes, stderr_truncated), status) =
254 joined.context("Failed to execute command")?;
255 Ok(ExecResult {
256 stdout: render_capped_output(&stdout_bytes, stdout_truncated),
257 stderr: render_capped_output(&stderr_bytes, stderr_truncated),
258 exit_code: status.code().unwrap_or(-1),
259 })
260 }
261 Err(_elapsed) => {
262 let _ = child.start_kill();
264 anyhow::bail!("Command timed out after {}ms", timeout.as_millis())
265 }
266 }
267 }
268
269 fn root(&self) -> &str {
270 self.root.to_str().unwrap_or_else(|| {
271 log::error!(
272 "LocalFileSystem root path contains invalid UTF-8: {}",
273 self.root.to_string_lossy()
274 );
275 "/"
276 })
277 }
278}
279
280impl LocalFileSystem {
281 async fn grep_file(
282 &self,
283 path: &Path,
284 regex: ®ex::Regex,
285 matches: &mut Vec<GrepMatch>,
286 ) -> Result<()> {
287 let content = tokio::fs::read(path).await?;
290 Self::grep_bytes(path, &content, regex, matches);
291 Ok(())
292 }
293
294 fn grep_bytes(path: &Path, content: &[u8], regex: ®ex::Regex, matches: &mut Vec<GrepMatch>) {
297 if content.iter().take(1024).any(|&b| b == 0) {
298 return; }
300 let text = String::from_utf8_lossy(content);
301 for (line_num, line) in text.lines().enumerate() {
302 if let Some(m) = regex.find(line) {
303 matches.push(GrepMatch {
304 path: path.to_string_lossy().to_string(),
305 line_number: line_num + 1,
306 line_content: line.to_string(),
307 match_start: m.start(),
308 match_end: m.end(),
309 });
310 }
311 }
312 }
313
314 async fn grep_dir(
315 &self,
316 start_dir: &Path,
317 regex: ®ex::Regex,
318 recursive: bool,
319 matches: &mut Vec<GrepMatch>,
320 ) -> Result<()> {
321 let mut dirs_to_process = vec![start_dir.to_path_buf()];
323
324 while let Some(dir) = dirs_to_process.pop() {
325 let Ok(mut entries) = tokio::fs::read_dir(&dir).await else {
326 continue; };
328
329 while let Ok(Some(entry)) = entries.next_entry().await {
330 let path = entry.path();
331 let Ok(metadata) = entry.metadata().await else {
332 continue;
333 };
334
335 if metadata.is_file() {
336 if metadata.len() > MAX_GREP_FILE_BYTES {
340 continue;
341 }
342 if let Ok(content) = tokio::fs::read(&path).await {
345 Self::grep_bytes(&path, &content, regex, matches);
346 }
347 } else if metadata.is_dir() && recursive {
348 dirs_to_process.push(path);
349 }
350 }
351 }
352 Ok(())
353 }
354}
355
356pub struct InMemoryFileSystem {
358 root: String,
359 files: RwLock<HashMap<String, Vec<u8>>>,
360 dirs: RwLock<std::collections::HashSet<String>>,
361}
362
363impl InMemoryFileSystem {
364 #[must_use]
365 pub fn new(root: impl Into<String>) -> Self {
366 let root = root.into();
367 let dirs = RwLock::new({
368 let mut set = std::collections::HashSet::new();
369 set.insert(root.clone());
370 set
371 });
372 Self {
373 root,
374 files: RwLock::new(HashMap::new()),
375 dirs,
376 }
377 }
378
379 fn normalize_path(&self, path: &str) -> String {
380 if path.starts_with('/') {
381 path.to_string()
382 } else {
383 format!("{}/{}", self.root.trim_end_matches('/'), path)
384 }
385 }
386
387 fn parent_dir(path: &str) -> Option<String> {
388 Path::new(path)
389 .parent()
390 .map(|p| p.to_string_lossy().to_string())
391 }
392}
393
394#[async_trait]
395impl Environment for InMemoryFileSystem {
396 async fn read_file(&self, path: &str) -> Result<String> {
397 let path = self.normalize_path(path);
398 self.files
399 .read()
400 .ok()
401 .context("lock poisoned")?
402 .get(&path)
403 .map(|bytes| String::from_utf8_lossy(bytes).to_string())
404 .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))
405 }
406
407 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
408 let path = self.normalize_path(path);
409 self.files
410 .read()
411 .ok()
412 .context("lock poisoned")?
413 .get(&path)
414 .cloned()
415 .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))
416 }
417
418 async fn write_file(&self, path: &str, content: &str) -> Result<()> {
419 self.write_file_bytes(path, content.as_bytes()).await
420 }
421
422 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
423 let path = self.normalize_path(path);
424
425 if let Some(parent) = Self::parent_dir(&path) {
427 self.create_dir(&parent).await?;
428 }
429
430 self.files
431 .write()
432 .ok()
433 .context("lock poisoned")?
434 .insert(path, content.to_vec());
435 Ok(())
436 }
437
438 async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>> {
439 let path = self.normalize_path(path);
440 let prefix = format!("{}/", path.trim_end_matches('/'));
441 let mut entries = Vec::new();
442
443 {
445 let dirs = self.dirs.read().ok().context("lock poisoned")?;
446 if !dirs.contains(&path) {
447 anyhow::bail!("Directory not found: {path}");
448 }
449
450 for dir_path in dirs.iter() {
452 if dir_path.starts_with(&prefix) && dir_path != &path {
453 let relative = &dir_path[prefix.len()..];
454 if !relative.contains('/') {
455 entries.push(FileEntry {
456 name: relative.to_string(),
457 path: dir_path.clone(),
458 is_dir: true,
459 size: None,
460 });
461 }
462 }
463 }
464 }
465
466 {
468 let files = self.files.read().ok().context("lock poisoned")?;
469 for (file_path, content) in files.iter() {
470 if file_path.starts_with(&prefix) {
471 let relative = &file_path[prefix.len()..];
472 if !relative.contains('/') {
473 entries.push(FileEntry {
474 name: relative.to_string(),
475 path: file_path.clone(),
476 is_dir: false,
477 size: Some(content.len() as u64),
478 });
479 }
480 }
481 }
482 }
483
484 Ok(entries)
485 }
486
487 async fn exists(&self, path: &str) -> Result<bool> {
488 let path = self.normalize_path(path);
489 let in_files = self
490 .files
491 .read()
492 .ok()
493 .context("lock poisoned")?
494 .contains_key(&path);
495 let in_dirs = self
496 .dirs
497 .read()
498 .ok()
499 .context("lock poisoned")?
500 .contains(&path);
501 Ok(in_files || in_dirs)
502 }
503
504 async fn is_dir(&self, path: &str) -> Result<bool> {
505 let path = self.normalize_path(path);
506 Ok(self
507 .dirs
508 .read()
509 .ok()
510 .context("lock poisoned")?
511 .contains(&path))
512 }
513
514 async fn is_file(&self, path: &str) -> Result<bool> {
515 let path = self.normalize_path(path);
516 Ok(self
517 .files
518 .read()
519 .ok()
520 .context("lock poisoned")?
521 .contains_key(&path))
522 }
523
524 async fn create_dir(&self, path: &str) -> Result<()> {
525 let path = self.normalize_path(path);
526
527 let mut current = String::new();
529 let dirs_to_create: Vec<String> = path
530 .split('/')
531 .filter(|p| !p.is_empty())
532 .map(|part| {
533 current = format!("{current}/{part}");
534 current.clone()
535 })
536 .collect();
537
538 for dir in dirs_to_create {
540 self.dirs.write().ok().context("lock poisoned")?.insert(dir);
541 }
542
543 Ok(())
544 }
545
546 async fn delete_file(&self, path: &str) -> Result<()> {
547 let path = self.normalize_path(path);
548 self.files
549 .write()
550 .ok()
551 .context("lock poisoned")?
552 .remove(&path)
553 .ok_or_else(|| anyhow::anyhow!("File not found: {path}"))?;
554 Ok(())
555 }
556
557 async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()> {
558 let path = self.normalize_path(path);
559 let prefix = format!("{}/", path.trim_end_matches('/'));
560
561 if !self
563 .dirs
564 .read()
565 .ok()
566 .context("lock poisoned")?
567 .contains(&path)
568 {
569 anyhow::bail!("Directory not found: {path}");
570 }
571
572 if recursive {
573 self.files
575 .write()
576 .ok()
577 .context("lock poisoned")?
578 .retain(|k, _| !k.starts_with(&prefix));
579 self.dirs
580 .write()
581 .ok()
582 .context("lock poisoned")?
583 .retain(|k| !k.starts_with(&prefix) && k != &path);
584 } else {
585 let has_files = self
587 .files
588 .read()
589 .ok()
590 .context("lock poisoned")?
591 .keys()
592 .any(|k| k.starts_with(&prefix));
593 let has_subdirs = self
594 .dirs
595 .read()
596 .ok()
597 .context("lock poisoned")?
598 .iter()
599 .any(|k| k.starts_with(&prefix) && k != &path);
600
601 if has_files || has_subdirs {
602 anyhow::bail!("Directory not empty: {path}");
603 }
604
605 self.dirs
606 .write()
607 .ok()
608 .context("lock poisoned")?
609 .remove(&path);
610 }
611
612 Ok(())
613 }
614
615 async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>> {
616 let path = self.normalize_path(path);
617 let regex = regex::Regex::new(pattern).context("Invalid regex pattern")?;
618 let mut matches = Vec::new();
619
620 let is_file = self
622 .files
623 .read()
624 .ok()
625 .context("lock poisoned")?
626 .contains_key(&path);
627 let is_dir = self
628 .dirs
629 .read()
630 .ok()
631 .context("lock poisoned")?
632 .contains(&path);
633
634 if is_file {
635 let content = self
637 .files
638 .read()
639 .ok()
640 .context("lock poisoned")?
641 .get(&path)
642 .cloned();
643 if let Some(content) = content {
644 let content = String::from_utf8_lossy(&content);
645 for (line_num, line) in content.lines().enumerate() {
646 if let Some(m) = regex.find(line) {
647 matches.push(GrepMatch {
648 path: path.clone(),
649 line_number: line_num + 1,
650 line_content: line.to_string(),
651 match_start: m.start(),
652 match_end: m.end(),
653 });
654 }
655 }
656 }
657 } else if is_dir {
658 let prefix = format!("{}/", path.trim_end_matches('/'));
660 let files_to_search: Vec<_> = {
661 let files = self.files.read().ok().context("lock poisoned")?;
662 files
663 .iter()
664 .filter(|(file_path, _)| {
665 if recursive {
666 file_path.starts_with(&prefix)
667 } else {
668 file_path.starts_with(&prefix)
669 && !file_path[prefix.len()..].contains('/')
670 }
671 })
672 .map(|(k, v)| (k.clone(), v.clone()))
673 .collect()
674 };
675
676 for (file_path, content) in files_to_search {
677 let content = String::from_utf8_lossy(&content);
678 for (line_num, line) in content.lines().enumerate() {
679 if let Some(m) = regex.find(line) {
680 matches.push(GrepMatch {
681 path: file_path.clone(),
682 line_number: line_num + 1,
683 line_content: line.to_string(),
684 match_start: m.start(),
685 match_end: m.end(),
686 });
687 }
688 }
689 }
690 }
691
692 Ok(matches)
693 }
694
695 async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
696 let pattern = self.normalize_path(pattern);
697
698 let mut escaped = String::with_capacity(pattern.len());
705 for c in pattern.chars() {
706 match c {
707 '.' | '+' | '^' | '$' | '(' | ')' | '{' | '}' | '|' | '\\' => {
708 escaped.push('\\');
709 escaped.push(c);
710 }
711 _ => escaped.push(c),
712 }
713 }
714
715 let regex_pattern = escaped
717 .replace("**", "\x00")
718 .replace('*', "[^/]*")
719 .replace('\x00', ".*")
720 .replace('?', ".");
721 let regex =
722 regex::Regex::new(&format!("^{regex_pattern}$")).context("Invalid glob pattern")?;
723
724 let mut matches: Vec<String> = self
726 .files
727 .read()
728 .ok()
729 .context("lock poisoned")?
730 .keys()
731 .filter(|p| regex.is_match(p))
732 .cloned()
733 .collect();
734
735 matches.extend(
736 self.dirs
737 .read()
738 .ok()
739 .context("lock poisoned")?
740 .iter()
741 .filter(|p| regex.is_match(p))
742 .cloned(),
743 );
744
745 matches.sort();
746 matches.dedup();
747 Ok(matches)
748 }
749
750 fn root(&self) -> &str {
751 &self.root
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[tokio::test]
760 async fn test_in_memory_write_and_read() -> Result<()> {
761 let fs = InMemoryFileSystem::new("/workspace");
762
763 fs.write_file("test.txt", "Hello, World!").await?;
764 let content = fs.read_file("test.txt").await?;
765
766 assert_eq!(content, "Hello, World!");
767 Ok(())
768 }
769
770 #[tokio::test]
771 async fn test_in_memory_exists() -> Result<()> {
772 let fs = InMemoryFileSystem::new("/workspace");
773
774 assert!(!fs.exists("test.txt").await?);
775 fs.write_file("test.txt", "content").await?;
776 assert!(fs.exists("test.txt").await?);
777 Ok(())
778 }
779
780 #[tokio::test]
781 async fn test_in_memory_directories() -> Result<()> {
782 let fs = InMemoryFileSystem::new("/workspace");
783
784 fs.create_dir("src/lib").await?;
785 assert!(fs.is_dir("src").await?);
786 assert!(fs.is_dir("src/lib").await?);
787 assert!(!fs.is_file("src").await?);
788 Ok(())
789 }
790
791 #[tokio::test]
792 async fn test_in_memory_list_dir() -> Result<()> {
793 let fs = InMemoryFileSystem::new("/workspace");
794
795 fs.write_file("file1.txt", "content1").await?;
796 fs.write_file("file2.txt", "content2").await?;
797 fs.create_dir("subdir").await?;
798
799 let entries = fs.list_dir("/workspace").await?;
800 assert_eq!(entries.len(), 3);
801
802 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
803 assert!(names.contains(&"file1.txt"));
804 assert!(names.contains(&"file2.txt"));
805 assert!(names.contains(&"subdir"));
806 Ok(())
807 }
808
809 #[tokio::test]
810 async fn test_in_memory_grep() -> Result<()> {
811 let fs = InMemoryFileSystem::new("/workspace");
812
813 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
814 .await?;
815
816 let matches = fs.grep("println", "/workspace", true).await?;
817 assert_eq!(matches.len(), 1);
818 assert_eq!(matches[0].line_number, 2);
819 assert!(matches[0].line_content.contains("println"));
820 Ok(())
821 }
822
823 #[tokio::test]
824 async fn test_in_memory_glob() -> Result<()> {
825 let fs = InMemoryFileSystem::new("/workspace");
826
827 fs.write_file("src/main.rs", "fn main() {}").await?;
828 fs.write_file("src/lib.rs", "pub mod foo;").await?;
829 fs.write_file("tests/test.rs", "// test").await?;
830
831 let matches = fs.glob("/workspace/src/*.rs").await?;
832 assert_eq!(matches.len(), 2);
833 Ok(())
834 }
835
836 #[tokio::test]
837 async fn test_in_memory_delete() -> Result<()> {
838 let fs = InMemoryFileSystem::new("/workspace");
839
840 fs.write_file("test.txt", "content").await?;
841 assert!(fs.exists("test.txt").await?);
842
843 fs.delete_file("test.txt").await?;
844 assert!(!fs.exists("test.txt").await?);
845 Ok(())
846 }
847
848 fn unique_temp_dir(tag: &str) -> PathBuf {
849 let nanos = std::time::SystemTime::now()
850 .duration_since(std::time::UNIX_EPOCH)
851 .map_or(0, |d| d.as_nanos());
852 std::env::temp_dir().join(format!("agent_sdk_fs_{tag}_{}_{nanos}", std::process::id()))
853 }
854
855 #[tokio::test]
856 async fn test_exec_timeout_kills_child_no_leak() -> Result<()> {
857 let tmp = unique_temp_dir("exec_leak");
858 tokio::fs::create_dir_all(&tmp).await?;
859 let marker = tmp.join("marker.txt");
860
861 let fs = LocalFileSystem::new(tmp.clone());
862 let command = format!("sleep 1; touch '{}'", marker.display());
865 let result = fs.exec(&command, Some(50)).await;
866
867 let Err(error) = result else {
868 anyhow::bail!("exec should have timed out");
869 };
870 let rendered = format!("{error:#}");
871 assert!(rendered.contains("timed out"), "got: {rendered}");
872
873 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
875 assert!(
876 !tokio::fs::try_exists(&marker).await.unwrap_or(false),
877 "child process leaked: marker created after timeout"
878 );
879
880 let _ = tokio::fs::remove_dir_all(&tmp).await;
881 Ok(())
882 }
883
884 #[tokio::test]
885 async fn test_exec_caps_large_output() -> Result<()> {
886 let tmp = unique_temp_dir("exec_cap");
887 tokio::fs::create_dir_all(&tmp).await?;
888 let fs = LocalFileSystem::new(tmp.clone());
889
890 let result = fs
892 .exec(
893 "dd if=/dev/zero bs=1000000 count=2 2>/dev/null",
894 Some(10_000),
895 )
896 .await?;
897
898 assert!(
899 result.stdout.contains("[output truncated"),
900 "expected truncation marker in capped output"
901 );
902 assert!(
904 result.stdout.len() <= MAX_EXEC_OUTPUT_BYTES + 64,
905 "captured output exceeded the cap: {} bytes",
906 result.stdout.len()
907 );
908
909 let _ = tokio::fs::remove_dir_all(&tmp).await;
910 Ok(())
911 }
912
913 #[tokio::test]
914 async fn test_local_grep_skips_binary_and_matches_text() -> Result<()> {
915 let tmp = unique_temp_dir("grep");
916 tokio::fs::create_dir_all(&tmp).await?;
917 tokio::fs::write(tmp.join("code.rs"), "fn TODO_here() {}\nlet x = 1;\n").await?;
918 let mut binary = vec![0u8, 1, 2, 3];
920 binary.extend_from_slice(b"TODO_here\n");
921 tokio::fs::write(tmp.join("blob.bin"), &binary).await?;
922
923 let fs = LocalFileSystem::new(tmp.clone());
924 let matches = fs.grep("TODO_here", ".", true).await?;
925
926 assert_eq!(matches.len(), 1, "binary file must be skipped: {matches:?}");
927 assert!(matches[0].path.ends_with("code.rs"));
928
929 let _ = tokio::fs::remove_dir_all(&tmp).await;
930 Ok(())
931 }
932
933 #[tokio::test]
934 async fn test_in_memory_glob_escapes_regex_metacharacters() -> Result<()> {
935 let fs = InMemoryFileSystem::new("/workspace");
936 fs.write_file("main.rs", "x").await?;
937 fs.write_file("mainXrs", "x").await?;
938
939 let matches = fs.glob("/workspace/*.rs").await?;
941 assert_eq!(matches, vec!["/workspace/main.rs".to_string()]);
942
943 fs.write_file("a+b.txt", "x").await?;
946 let plus = fs.glob("/workspace/a+b.txt").await?;
947 assert_eq!(plus, vec!["/workspace/a+b.txt".to_string()]);
948
949 Ok(())
950 }
951}