1use std::path::{Path, PathBuf};
22use std::process::Stdio;
23use std::time::Duration;
24
25#[cfg(not(unix))]
26use agent_workspace_contract::WORKSPACE_MAX_FILE_BYTES;
27use agent_workspace_contract::{
28 CmdOutput, DirEntry, FileVisibilityOptions, GrepMatch, PortUrl, Workspace,
29 WORKSPACE_MAX_COMMAND_BYTES, WORKSPACE_MAX_COMMAND_OUTPUT_BYTES, WORKSPACE_MAX_RESULTS,
30 WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
31};
32use anyhow::{Context, Result};
33use async_trait::async_trait;
34#[cfg(not(unix))]
35use sha2::{Digest as _, Sha256};
36#[cfg(not(unix))]
37use tokio::io::AsyncWriteExt;
38use tokio::io::{AsyncRead, AsyncReadExt};
39
40mod executor;
41mod local_environment;
42pub use local_environment::*;
43mod path_resolver;
44mod provider;
45mod search;
46#[cfg(unix)]
47mod secure_fs;
48mod snapshot_object_store;
49mod snapshot_provider;
50pub use executor::*;
51use path_resolver::{normalize_path, WorkspacePathResolver};
52pub use provider::*;
53#[cfg(unix)]
54use secure_fs::SecureRoot;
55pub use snapshot_object_store::*;
56pub use snapshot_provider::*;
57
58const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
59
60#[derive(Debug, Clone)]
65pub struct LocalWorkspace {
66 root: PathBuf,
67 allow_host_exec: bool,
68 #[cfg(unix)]
69 secure_root: SecureRoot,
70}
71
72#[cfg(test)]
73mod tests;
74
75impl LocalWorkspace {
76 pub async fn write_upload_chunk(&self, path: &str, offset: u64, bytes: &[u8]) -> Result<u64> {
77 #[cfg(unix)]
78 {
79 let secure = self.secure_root.clone();
80 let path = path.to_string();
81 let bytes = bytes.to_vec();
82 tokio::task::spawn_blocking(move || secure.write_chunk(&path, offset, &bytes))
83 .await
84 .context("joining descriptor-relative upload write")?
85 }
86 #[cfg(not(unix))]
87 {
88 let mut current = if self.exists(path).await? {
89 self.read_file_bytes(path).await?
90 } else {
91 Vec::new()
92 };
93 anyhow::ensure!(current.len() as u64 == offset, "upload offset conflict");
94 current.extend_from_slice(bytes);
95 self.write_file_bytes(path, ¤t).await?;
96 Ok(current.len() as u64)
97 }
98 }
99 pub fn new(root: PathBuf) -> Self {
100 Self::new_with_host_exec(root, false)
101 }
102
103 pub fn new_with_host_exec(root: PathBuf, allow_host_exec: bool) -> Self {
105 let root = if root.is_absolute() {
106 root
107 } else {
108 std::env::current_dir()
109 .map(|current| current.join(&root))
110 .unwrap_or(root)
111 };
112 Self {
113 root: normalize_path(&root),
114 allow_host_exec,
115 #[cfg(unix)]
116 secure_root: SecureRoot::new(normalize_path(&root)),
117 }
118 }
119
120 pub fn capabilities(&self) -> agent_workspace_contract::WorkspaceCapabilities {
121 agent_workspace_contract::WorkspaceCapabilities {
122 commands: self.allow_host_exec,
123 previews: true,
124 atomic_writes: true,
125 compare_and_swap: true,
126 ..Default::default()
127 }
128 }
129
130 fn resolve(&self, path: &str) -> Result<PathBuf> {
132 WorkspacePathResolver::new(&self.root).lexical(path)
133 }
134
135 #[cfg(not(unix))]
136 async fn canonical_root(&self) -> Result<PathBuf> {
137 WorkspacePathResolver::new(&self.root)
138 .canonical_root()
139 .await
140 }
141
142 async fn resolve_existing(&self, path: &str) -> Result<PathBuf> {
143 WorkspacePathResolver::new(&self.root).existing(path).await
144 }
145
146 #[cfg(not(unix))]
147 async fn resolve_for_write(&self, path: &str) -> Result<PathBuf> {
148 WorkspacePathResolver::new(&self.root).for_write(path).await
149 }
150
151 #[cfg(not(unix))]
152 async fn atomic_write_unlocked(&self, path: &str, content: &[u8]) -> Result<()> {
153 anyhow::ensure!(
154 content.len() <= WORKSPACE_MAX_FILE_BYTES,
155 "workspace file is too large"
156 );
157 let target = self.resolve_for_write(path).await?;
158 let parent = target.parent().context("workspace file has no parent")?;
159 tokio::fs::create_dir_all(parent).await?;
160 let target = self.resolve_for_write(path).await?;
163 let temp = parent.join(format!(
164 ".agent-write-{}.tmp",
165 uuid::Uuid::new_v4().simple()
166 ));
167 let result = async {
168 let mut file = tokio::fs::OpenOptions::new()
169 .create_new(true)
170 .write(true)
171 .open(&temp)
172 .await?;
173 file.write_all(content).await?;
174 file.sync_all().await?;
175 drop(file);
176 tokio::fs::rename(&temp, &target).await?;
177 Ok::<_, anyhow::Error>(())
178 }
179 .await;
180 if result.is_err() {
181 let _ = tokio::fs::remove_file(&temp).await;
182 }
183 result.with_context(|| format!("failed to atomically write {}", target.display()))
184 }
185
186 #[cfg(not(unix))]
187 async fn revision_for_existing(&self, path: &Path) -> Result<String> {
188 let mut file = tokio::fs::File::open(path).await?;
189 let mut hash = Sha256::new();
190 let mut total = 0_usize;
191 let mut buffer = [0_u8; 16 * 1024];
192 loop {
193 let read = file.read(&mut buffer).await?;
194 if read == 0 {
195 break;
196 }
197 total = total.saturating_add(read);
198 anyhow::ensure!(
199 total <= WORKSPACE_MAX_FILE_BYTES,
200 "workspace file exceeds {WORKSPACE_MAX_FILE_BYTES} bytes"
201 );
202 hash.update(&buffer[..read]);
203 }
204 Ok(format!("sha256:{:x}", hash.finalize()))
205 }
206
207 async fn list_dir_inner(
208 &self,
209 path: &str,
210 options: Option<FileVisibilityOptions>,
211 ) -> Result<Vec<DirEntry>> {
212 #[cfg(unix)]
213 {
214 let secure = self.secure_root.clone();
215 let path = path.to_string();
216 tokio::task::spawn_blocking(move || secure.list_dir(&path, options))
217 .await
218 .context("joining descriptor-relative directory listing")?
219 }
220 #[cfg(not(unix))]
221 {
222 let target = self.resolve_existing(path).await?;
223 let mut entries = Vec::new();
224 let mut read_dir = tokio::fs::read_dir(&target)
225 .await
226 .with_context(|| format!("failed to list {}", target.display()))?;
227 while let Some(entry) = read_dir.next_entry().await? {
228 anyhow::ensure!(
229 entries.len() < WORKSPACE_MAX_RESULTS,
230 "directory contains too many entries"
231 );
232 let name = entry.file_name().to_string_lossy().to_string();
233 if options
234 .map(|options| !search::is_visible_name(&name, options))
235 .unwrap_or(false)
236 {
237 continue;
238 }
239 let metadata = tokio::fs::symlink_metadata(entry.path()).await?;
240 entries.push(DirEntry {
241 name,
242 is_dir: metadata.is_dir(),
243 size: if metadata.is_file() {
244 Some(metadata.len())
245 } else {
246 None
247 },
248 });
249 }
250 entries.sort_by(|a, b| a.name.cmp(&b.name));
251 Ok(entries)
252 }
253 }
254
255 async fn walk_tree_inner(
256 &self,
257 path: &str,
258 max_depth: usize,
259 options: Option<FileVisibilityOptions>,
260 ) -> Result<Vec<String>> {
261 #[cfg(unix)]
262 {
263 let secure = self.secure_root.clone();
264 let path = path.to_string();
265 tokio::task::spawn_blocking(move || secure.walk(&path, max_depth, options))
266 .await
267 .context("joining descriptor-relative tree walk")?
268 }
269 #[cfg(not(unix))]
270 {
271 let target = self.resolve_existing(path).await?;
272 let mut results = Vec::new();
273 let mut output_bytes = 0;
274 let root = self.canonical_root().await?;
275 self.walk_dir(
276 &target,
277 max_depth.min(64),
278 &mut results,
279 &mut output_bytes,
280 &root,
281 options,
282 )?;
283 Ok(results)
284 }
285 }
286
287 #[cfg(not(unix))]
288 fn walk_dir(
289 &self,
290 dir: &Path,
291 remaining_depth: usize,
292 results: &mut Vec<String>,
293 output_bytes: &mut usize,
294 root: &Path,
295 options: Option<FileVisibilityOptions>,
296 ) -> Result<()> {
297 if remaining_depth == 0 {
298 return Ok(());
299 }
300 let read_dir =
301 std::fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))?;
302 for entry in read_dir {
303 let entry = entry?;
304 let name = entry.file_name().to_string_lossy().to_string();
305 if options
306 .map(|opt| !search::is_visible_name(&name, opt))
307 .unwrap_or(false)
308 {
309 continue;
310 }
311 let path = entry.path();
312 anyhow::ensure!(
313 results.len() < WORKSPACE_MAX_RESULTS,
314 "workspace tree result limit exceeded"
315 );
316 let relative = relative_path(root, &path)?;
317 *output_bytes = output_bytes.saturating_add(relative.len());
318 anyhow::ensure!(
319 *output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
320 "workspace tree output limit exceeded"
321 );
322 results.push(relative);
323 if entry.file_type()?.is_dir() {
324 self.walk_dir(
325 &path,
326 remaining_depth - 1,
327 results,
328 output_bytes,
329 root,
330 options,
331 )?;
332 }
333 }
334 Ok(())
335 }
336}
337
338#[async_trait]
339impl agent_workspace_contract::WorkspaceUploadWriter for LocalWorkspace {
340 async fn write_upload_chunk(&self, path: &str, offset: u64, bytes: &[u8]) -> Result<u64> {
341 LocalWorkspace::write_upload_chunk(self, path, offset, bytes).await
342 }
343}
344
345#[async_trait]
346impl Workspace for LocalWorkspace {
347 fn description(&self) -> String {
348 format!("local:{}", self.root.display())
349 }
350
351 fn root(&self) -> PathBuf {
352 self.root.clone()
353 }
354
355 fn capabilities(&self) -> agent_workspace_contract::WorkspaceCapabilities {
356 LocalWorkspace::capabilities(self)
357 }
358
359 async fn read_file(&self, path: &str) -> Result<String> {
360 let bytes = self.read_file_bytes(path).await?;
361 String::from_utf8(bytes).context("workspace file is not valid UTF-8")
362 }
363
364 async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>> {
365 #[cfg(unix)]
366 {
367 let secure = self.secure_root.clone();
368 let path = path.to_string();
369 return tokio::task::spawn_blocking(move || secure.read(&path))
370 .await
371 .context("joining descriptor-relative read")?;
372 }
373 #[cfg(not(unix))]
374 {
375 let target = self.resolve_existing(path).await?;
376 let file = tokio::fs::File::open(&target)
377 .await
378 .with_context(|| format!("failed to read {}", target.display()))?;
379 let (bytes, truncated) = read_bounded(file, WORKSPACE_MAX_FILE_BYTES).await?;
380 anyhow::ensure!(
381 !truncated,
382 "workspace file exceeds {WORKSPACE_MAX_FILE_BYTES} bytes"
383 );
384 Ok(bytes)
385 }
386 }
387
388 async fn write_file(&self, path: &str, content: &str) -> Result<()> {
389 self.write_file_bytes(path, content.as_bytes()).await
390 }
391
392 async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()> {
393 self.write_file_if_match(path, content, None)
394 .await
395 .map(|_| ())
396 }
397
398 async fn file_revision(&self, path: &str) -> Result<Option<String>> {
399 #[cfg(unix)]
400 {
401 let secure = self.secure_root.clone();
402 let path = path.to_string();
403 return tokio::task::spawn_blocking(move || secure.revision(&path))
404 .await
405 .context("joining descriptor-relative revision")?;
406 }
407 #[cfg(not(unix))]
408 {
409 match tokio::fs::symlink_metadata(self.resolve(path)?).await {
410 Ok(_) => {
411 let target = self.resolve_existing(path).await?;
412 self.revision_for_existing(&target).await.map(Some)
413 }
414 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
415 Err(error) => Err(error.into()),
416 }
417 }
418 }
419
420 async fn write_file_if_match(
421 &self,
422 path: &str,
423 content: &[u8],
424 if_match: Option<&str>,
425 ) -> Result<String> {
426 #[cfg(unix)]
427 {
428 let secure = self.secure_root.clone();
429 let path = path.to_string();
430 let content = content.to_vec();
431 let expected = if_match.map(str::to_string);
432 return tokio::task::spawn_blocking(move || {
433 secure.write_if_match(&path, &content, expected.as_deref())
434 })
435 .await
436 .context("joining descriptor-relative write")?;
437 }
438 #[cfg(not(unix))]
439 {
440 if let Some(expected) = if_match {
441 let actual = match tokio::fs::symlink_metadata(self.resolve(path)?).await {
442 Ok(_) => {
443 let target = self.resolve_existing(path).await?;
444 Some(self.revision_for_existing(&target).await?)
445 }
446 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
447 Err(error) => return Err(error.into()),
448 };
449 if !agent_workspace_contract::revision_matches(expected, actual.as_deref()) {
450 return Err(agent_workspace_contract::WorkspaceVersionConflict {
451 expected: expected.to_string(),
452 actual,
453 }
454 .into());
455 }
456 }
457 self.atomic_write_unlocked(path, content).await?;
458 Ok(agent_workspace_contract::content_revision(content))
459 }
460 }
461
462 async fn create_dir_all(&self, path: &str) -> Result<()> {
463 #[cfg(unix)]
464 {
465 let secure = self.secure_root.clone();
466 let path = path.to_string();
467 return tokio::task::spawn_blocking(move || secure.create_dir_all(&path))
468 .await
469 .context("joining descriptor-relative mkdir")?;
470 }
471 #[cfg(not(unix))]
472 {
473 let target = self.resolve_for_write(path).await?;
474 tokio::fs::create_dir_all(&target)
475 .await
476 .with_context(|| format!("failed to create directory {}", target.display()))
477 }
478 }
479
480 async fn remove_file(&self, path: &str) -> Result<()> {
481 #[cfg(unix)]
482 {
483 let secure = self.secure_root.clone();
484 let path = path.to_string();
485 return tokio::task::spawn_blocking(move || secure.remove(&path))
486 .await
487 .context("joining descriptor-relative remove")?;
488 }
489 #[cfg(not(unix))]
490 {
491 let target = self.resolve_existing(path).await?;
492 tokio::fs::remove_file(&target)
493 .await
494 .with_context(|| format!("failed to remove {}", target.display()))
495 }
496 }
497
498 async fn remove_file_if_match(&self, path: &str, if_match: &str) -> Result<()> {
499 #[cfg(unix)]
500 {
501 let secure = self.secure_root.clone();
502 let path = path.to_string();
503 let expected = if_match.to_string();
504 return tokio::task::spawn_blocking(move || {
505 secure.remove_if_match(&path, Some(&expected))
506 })
507 .await
508 .context("joining descriptor-relative conditional remove")?;
509 }
510 #[cfg(not(unix))]
511 {
512 let actual = self.file_revision(path).await?;
513 if !agent_workspace_contract::revision_matches(if_match, actual.as_deref()) {
514 return Err(agent_workspace_contract::WorkspaceVersionConflict {
515 expected: if_match.to_string(),
516 actual,
517 }
518 .into());
519 }
520 self.remove_file(path).await
521 }
522 }
523
524 async fn exists(&self, path: &str) -> Result<bool> {
525 let target = self.resolve(path)?;
526 match tokio::fs::symlink_metadata(&target).await {
527 Ok(_) => {
528 self.resolve_existing(path).await?;
529 Ok(true)
530 }
531 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
532 Err(error) => Err(error.into()),
533 }
534 }
535
536 async fn is_dir(&self, path: &str) -> Result<bool> {
537 if !self.exists(path).await? {
538 return Ok(false);
539 }
540 Ok(tokio::fs::metadata(self.resolve_existing(path).await?)
541 .await?
542 .is_dir())
543 }
544
545 async fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
546 self.list_dir_inner(path, None).await
547 }
548
549 async fn list_dir_with_options(
550 &self,
551 path: &str,
552 options: FileVisibilityOptions,
553 ) -> Result<Vec<DirEntry>> {
554 self.list_dir_inner(path, Some(options)).await
555 }
556
557 async fn walk_tree(&self, path: &str, max_depth: usize) -> Result<Vec<String>> {
558 self.walk_tree_inner(path, max_depth, None).await
559 }
560
561 async fn walk_tree_with_options(
562 &self,
563 path: &str,
564 max_depth: usize,
565 options: FileVisibilityOptions,
566 ) -> Result<Vec<String>> {
567 self.walk_tree_inner(path, max_depth, Some(options)).await
568 }
569
570 async fn find_files(&self, pattern: &str, path: &str) -> Result<Vec<String>> {
571 #[cfg(unix)]
572 {
573 let secure = self.secure_root.clone();
574 let path = path.to_string();
575 let pattern = glob::Pattern::new(pattern)?;
576 return tokio::task::spawn_blocking(move || {
577 let mut output_bytes = 0_usize;
578 let mut result = Vec::new();
579 for candidate in secure.walk(&path, 64, None)? {
580 let name = Path::new(&candidate)
581 .file_name()
582 .and_then(|value| value.to_str())
583 .unwrap_or("");
584 if pattern.matches(name) && secure.is_regular_file(&candidate)? {
585 output_bytes = output_bytes.saturating_add(candidate.len());
586 anyhow::ensure!(
587 output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
588 "find output limit exceeded"
589 );
590 result.push(candidate);
591 }
592 }
593 Ok(result)
594 })
595 .await
596 .context("joining descriptor-relative file search")?;
597 }
598 #[cfg(not(unix))]
599 {
600 let target = self.resolve_existing(path).await?;
601 let pattern = glob::Pattern::new(pattern)?;
602 let mut results = Vec::new();
603 let mut output_bytes = 0;
604 let root = self.canonical_root().await?;
605 self.find_files_inner(&target, &pattern, &mut results, &mut output_bytes, &root)?;
606 Ok(results)
607 }
608 }
609
610 async fn grep(
611 &self,
612 pattern: &str,
613 path: &str,
614 include: Option<&str>,
615 ) -> Result<Vec<GrepMatch>> {
616 #[cfg(unix)]
617 {
618 let secure = self.secure_root.clone();
619 let path = path.to_string();
620 let regex = regex::Regex::new(pattern)?;
621 let include_pattern = include.map(glob::Pattern::new).transpose()?;
622 return tokio::task::spawn_blocking(move || {
623 let mut result = Vec::new();
624 let mut output_bytes = 0_usize;
625 for candidate in secure.walk(&path, 64, None)? {
626 let name = Path::new(&candidate)
627 .file_name()
628 .and_then(|value| value.to_str())
629 .unwrap_or("");
630 if !secure.is_regular_file(&candidate)?
631 || !include_pattern
632 .as_ref()
633 .is_none_or(|value| value.matches(name))
634 {
635 continue;
636 }
637 let bytes = secure.read(&candidate)?;
638 let content = String::from_utf8_lossy(&bytes);
639 for (line_number, line) in content.lines().enumerate() {
640 if regex.is_match(line) {
641 anyhow::ensure!(
642 result.len() < WORKSPACE_MAX_RESULTS,
643 "grep result limit exceeded"
644 );
645 output_bytes = output_bytes
646 .saturating_add(candidate.len())
647 .saturating_add(line.len());
648 anyhow::ensure!(
649 output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
650 "grep output limit exceeded"
651 );
652 result.push(GrepMatch {
653 file: candidate.clone(),
654 line_number: line_number + 1,
655 line: line.into(),
656 });
657 }
658 }
659 }
660 Ok(result)
661 })
662 .await
663 .context("joining descriptor-relative grep")?;
664 }
665 #[cfg(not(unix))]
666 {
667 let target = self.resolve_existing(path).await?;
668 let regex = regex::Regex::new(pattern)?;
669 let include_pattern = include.map(glob::Pattern::new).transpose()?;
670 let mut results = Vec::new();
671 let mut output_bytes = 0;
672 let root = self.canonical_root().await?;
673 self.grep_inner(
674 &target,
675 ®ex,
676 include_pattern.as_ref(),
677 &mut results,
678 &mut output_bytes,
679 &root,
680 )?;
681 Ok(results)
682 }
683 }
684
685 async fn exec(&self, command: &str, cwd: Option<&str>) -> Result<CmdOutput> {
686 anyhow::ensure!(
687 self.allow_host_exec,
688 "host command execution is disabled for this local workspace"
689 );
690 anyhow::ensure!(
691 command.len() <= WORKSPACE_MAX_COMMAND_BYTES,
692 "workspace command exceeds {} bytes",
693 WORKSPACE_MAX_COMMAND_BYTES
694 );
695 let cwd = match cwd {
696 Some(c) => self.resolve_existing(c).await?,
697 None => self.resolve_existing(".").await?,
698 };
699 let mut child = tokio::process::Command::new("sh")
700 .arg("-lc")
701 .arg(command)
702 .current_dir(&cwd)
703 .stdout(Stdio::piped())
704 .stderr(Stdio::piped())
705 .kill_on_drop(true)
706 .spawn()
707 .with_context(|| format!("failed to execute: {command}"))?;
708 let stdout = child
709 .stdout
710 .take()
711 .context("command stdout was not captured")?;
712 let stderr = child
713 .stderr
714 .take()
715 .context("command stderr was not captured")?;
716 let stdout_task = tokio::spawn(read_bounded(stdout, WORKSPACE_MAX_COMMAND_OUTPUT_BYTES));
717 let stderr_task = tokio::spawn(read_bounded(stderr, WORKSPACE_MAX_COMMAND_OUTPUT_BYTES));
718
719 let status = match tokio::time::timeout(COMMAND_TIMEOUT, child.wait()).await {
720 Ok(result) => result?,
721 Err(_) => {
722 child.kill().await?;
723 let _ = child.wait().await;
724 anyhow::bail!("workspace command timed out after 60 seconds");
725 }
726 };
727 let (stdout, stdout_truncated) = stdout_task.await.context("joining stdout reader")??;
728 let (stderr, stderr_truncated) = stderr_task.await.context("joining stderr reader")??;
729
730 Ok(CmdOutput {
731 exit_code: status.code().unwrap_or(-1),
732 stdout: String::from_utf8_lossy(&stdout).to_string(),
733 stderr: String::from_utf8_lossy(&stderr).to_string(),
734 stdout_truncated,
735 stderr_truncated,
736 })
737 }
738
739 async fn port_url(&self, port: u16) -> Result<PortUrl> {
740 Ok(PortUrl {
741 port,
742 url: format!("http://localhost:{port}"),
743 host: Some("localhost".to_string()),
744 token: None,
745 signed: false,
746 expires_in_seconds: None,
747 })
748 }
749}
750
751impl LocalWorkspace {
752 #[cfg(not(unix))]
753 fn find_files_inner(
754 &self,
755 dir: &Path,
756 pattern: &glob::Pattern,
757 results: &mut Vec<String>,
758 output_bytes: &mut usize,
759 root: &Path,
760 ) -> Result<()> {
761 let read_dir =
762 std::fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))?;
763 for entry in read_dir {
764 let entry = entry?;
765 let path = entry.path();
766 let name = entry.file_name().to_string_lossy().to_string();
767 let file_type = entry.file_type()?;
768 if file_type.is_dir() {
769 self.find_files_inner(&path, pattern, results, output_bytes, root)?;
770 } else if file_type.is_file() && pattern.matches(&name) {
771 anyhow::ensure!(
772 results.len() < WORKSPACE_MAX_RESULTS,
773 "find result limit exceeded"
774 );
775 let relative = relative_path(root, &path)?;
776 *output_bytes = output_bytes.saturating_add(relative.len());
777 anyhow::ensure!(
778 *output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
779 "find output limit exceeded"
780 );
781 results.push(relative);
782 }
783 }
784 Ok(())
785 }
786
787 #[cfg(not(unix))]
788 fn grep_inner(
789 &self,
790 dir: &Path,
791 regex: ®ex::Regex,
792 include_pattern: Option<&glob::Pattern>,
793 results: &mut Vec<GrepMatch>,
794 output_bytes: &mut usize,
795 root: &Path,
796 ) -> Result<()> {
797 let read_dir =
798 std::fs::read_dir(dir).with_context(|| format!("failed to list {}", dir.display()))?;
799 for entry in read_dir {
800 let entry = entry?;
801 let path = entry.path();
802 let name = entry.file_name().to_string_lossy().to_string();
803 let file_type = entry.file_type()?;
804 if file_type.is_dir() {
805 self.grep_inner(&path, regex, include_pattern, results, output_bytes, root)?;
806 } else if file_type.is_file() && include_pattern.is_none_or(|p| p.matches(&name)) {
807 anyhow::ensure!(
808 entry.metadata()?.len() <= WORKSPACE_MAX_FILE_BYTES as u64,
809 "grep input file is too large: {}",
810 path.display()
811 );
812 if let Ok(content) = std::fs::read_to_string(&path) {
813 for (line_number, line) in content.lines().enumerate() {
814 if regex.is_match(line) {
815 anyhow::ensure!(
816 results.len() < WORKSPACE_MAX_RESULTS,
817 "grep result limit exceeded"
818 );
819 let file = relative_path(root, &path)?;
820 *output_bytes = output_bytes
821 .saturating_add(file.len())
822 .saturating_add(line.len());
823 anyhow::ensure!(
824 *output_bytes <= WORKSPACE_MAX_SEARCH_OUTPUT_BYTES,
825 "grep output limit exceeded"
826 );
827 results.push(GrepMatch {
828 file,
829 line_number: line_number + 1,
830 line: line.to_string(),
831 });
832 }
833 }
834 }
835 }
836 }
837 Ok(())
838 }
839}
840
841#[cfg(not(unix))]
842fn relative_path(root: &Path, path: &Path) -> Result<String> {
843 Ok(path
844 .strip_prefix(root)
845 .context("workspace traversal escaped the root")?
846 .to_string_lossy()
847 .to_string())
848}
849
850async fn read_bounded<Reader>(mut reader: Reader, limit: usize) -> std::io::Result<(Vec<u8>, bool)>
851where
852 Reader: AsyncRead + Unpin,
853{
854 let mut bytes = Vec::with_capacity(limit.min(64 * 1024));
855 let mut buffer = [0u8; 16 * 1024];
856 let mut truncated = false;
857 loop {
858 let read = reader.read(&mut buffer).await?;
859 if read == 0 {
860 break;
861 }
862 let remaining = limit.saturating_sub(bytes.len());
863 let retained = read.min(remaining);
864 bytes.extend_from_slice(&buffer[..retained]);
865 truncated |= retained < read;
866 }
867 Ok((bytes, truncated))
868}
869
870pub async fn create_local_workspace(root: PathBuf) -> Result<Box<dyn Workspace>> {
872 let ws = LocalWorkspace::new(root);
873 ws.init().await?;
874 Ok(Box::new(ws))
875}