1use anda_core::{
11 BoxError, RequestMeta, ToolGroupInfo, platform_text_encoding, text_encoding_for_label,
12 text_encoding_label, text_from_bytes_with_encoding,
13};
14use encoding_rs::Encoding;
15use std::{
16 ffi::OsString,
17 fmt,
18 fs::{Metadata, Permissions},
19 path::{Component, Path, PathBuf},
20};
21use tokio::io::AsyncWriteExt;
22
23mod edit;
24mod read;
25mod search;
26mod write;
27
28pub use edit::*;
29pub use read::*;
30pub use search::*;
31pub use write::*;
32
33pub const FS_TOOL_GROUP_ID: &str = "fs_workspace";
35
36pub fn fs_tool_group_info() -> ToolGroupInfo {
42 ToolGroupInfo {
43 id: FS_TOOL_GROUP_ID.to_string(),
44 title: "Filesystem workspace".to_string(),
45 description: "Read, search, edit, and write files within the agent's sandboxed workspace directories.".to_string(),
46 instructions: Some(
47 "These tools share one set of sandboxed workspace directories; paths are workspace-relative and access outside the workspace is denied. Typical flow: use `search_file` to locate content and `read_file` to inspect it (paging large files with offset/limit), then `edit_file` for targeted in-place changes or `write_file` to create or replace a whole file.".to_string(),
48 ),
49 }
50}
51
52pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
53
54pub(crate) const MAX_INLINE_CONTENT_BYTES: usize = 256 * 1024;
57
58pub(crate) const UTF8_ENCODING: &str = "utf8";
59pub(crate) const BASE64_ENCODING: &str = "base64";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub(crate) struct DecodedFileText {
63 pub(crate) text: String,
64 pub(crate) encoding: String,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) enum FileTextEncodeError {
69 UnsupportedEncoding,
70 UnmappableCharacters,
71}
72
73impl fmt::Display for FileTextEncodeError {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::UnsupportedEncoding => f.write_str("unsupported text encoding"),
77 Self::UnmappableCharacters => f.write_str(
78 "content contains characters not representable in the requested encoding",
79 ),
80 }
81 }
82}
83
84impl std::error::Error for FileTextEncodeError {}
85
86#[derive(Debug, Clone)]
87pub(crate) struct ResolvedFilePath {
88 pub(crate) workspace: PathBuf,
89 pub(crate) path: PathBuf,
90}
91
92#[derive(Debug, Clone)]
101pub(crate) struct WorkspaceScope {
102 workspaces: Vec<PathBuf>,
103}
104
105#[derive(Debug)]
110pub(crate) struct ReadTarget {
111 pub(crate) workspace: PathBuf,
112 pub(crate) path: PathBuf,
113 pub(crate) metadata: Metadata,
114}
115
116#[derive(Debug)]
122pub(crate) struct WriteTarget {
123 pub(crate) workspace: PathBuf,
124 pub(crate) path: PathBuf,
125 pub(crate) existing: Option<Metadata>,
126 requested: String,
127}
128
129impl WorkspaceScope {
130 pub(crate) async fn for_call(meta: &RequestMeta, defaults: &[PathBuf]) -> Self {
133 Self {
134 workspaces: tool_workspaces(meta, defaults).await,
135 }
136 }
137
138 pub(crate) fn roots(&self) -> &[PathBuf] {
140 &self.workspaces
141 }
142
143 pub(crate) fn into_primary(self) -> Option<PathBuf> {
145 self.workspaces.into_iter().next()
146 }
147
148 pub(crate) fn display(&self) -> String {
150 format_workspaces(&self.workspaces)
151 }
152
153 pub(crate) async fn open_read(&self, user_path: &str) -> Result<ReadTarget, BoxError> {
159 let resolved = resolve_read_path_in_workspaces(&self.workspaces, user_path).await?;
160 let metadata = read_target_metadata(&resolved, user_path).await?;
161 ensure_regular_file(
162 &metadata,
163 &resolved.path,
164 "Reading multiply-linked file is not allowed",
165 )?;
166 ensure_file_size_within_limit(&metadata, &resolved.path, MAX_FILE_SIZE_BYTES)?;
167
168 Ok(ReadTarget {
169 workspace: resolved.workspace,
170 path: resolved.path,
171 metadata,
172 })
173 }
174
175 pub(crate) async fn open_edit(&self, user_path: &str) -> Result<ReadTarget, BoxError> {
181 let resolved = resolve_write_path_in_workspaces(&self.workspaces, user_path).await?;
182 let metadata = match tokio::fs::metadata(&resolved.path).await {
183 Ok(metadata) => metadata,
184 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
185 return Err(format!(
186 "Path does not point to an existing file (workspace: {}, requested_path: {}, resolved_path: {})",
187 resolved.workspace.display(),
188 user_path,
189 resolved.path.display()
190 )
191 .into());
192 }
193 Err(err) => {
194 return Err(metadata_error(&resolved, user_path, err));
195 }
196 };
197
198 ensure_regular_file(
199 &metadata,
200 &resolved.path,
201 "Editing multiply-linked files is not allowed",
202 )?;
203 ensure_file_size_within_limit(&metadata, &resolved.path, MAX_FILE_SIZE_BYTES)?;
204
205 Ok(ReadTarget {
206 workspace: resolved.workspace,
207 path: resolved.path,
208 metadata,
209 })
210 }
211
212 pub(crate) async fn open_write(&self, user_path: &str) -> Result<WriteTarget, BoxError> {
218 let resolved = resolve_write_path_in_workspaces(&self.workspaces, user_path).await?;
219 let existing = match tokio::fs::metadata(&resolved.path).await {
220 Ok(metadata) => {
221 ensure_regular_file(
222 &metadata,
223 &resolved.path,
224 "Writing multiply-linked files is not allowed",
225 )?;
226 Some(metadata)
227 }
228 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
229 Err(err) => {
230 return Err(metadata_error(&resolved, user_path, err));
231 }
232 };
233
234 Ok(WriteTarget {
235 workspace: resolved.workspace,
236 path: resolved.path,
237 existing,
238 requested: user_path.to_string(),
239 })
240 }
241}
242
243async fn read_target_metadata(
244 resolved: &ResolvedFilePath,
245 user_path: &str,
246) -> Result<Metadata, BoxError> {
247 tokio::fs::metadata(&resolved.path)
248 .await
249 .map_err(|err| metadata_error(resolved, user_path, err))
250}
251
252fn metadata_error(resolved: &ResolvedFilePath, user_path: &str, err: std::io::Error) -> BoxError {
253 format!(
254 "Failed to read file metadata (workspace: {}, requested_path: {}, resolved_path: {}): {err}",
255 resolved.workspace.display(),
256 user_path,
257 resolved.path.display()
258 )
259 .into()
260}
261
262impl ReadTarget {
263 pub(crate) async fn write_atomic(&self, data: &[u8]) -> Result<(), BoxError> {
265 atomic_write_file(&self.path, data, Some(&self.metadata.permissions())).await
266 }
267}
268
269impl WriteTarget {
270 pub(crate) async fn write_atomic(&self, data: &[u8]) -> Result<(), BoxError> {
275 if self.existing.is_none()
276 && let Some(parent) = self.path.parent()
277 {
278 tokio::fs::create_dir_all(parent).await.map_err(|err| {
279 format!(
280 "Failed to create parent directories (workspace: {}, requested_path: {}, resolved_path: {}, parent_path: {}): {err}",
281 self.workspace.display(),
282 self.requested,
283 self.path.display(),
284 parent.display()
285 )
286 })?;
287 }
288
289 let permissions = self
290 .existing
291 .as_ref()
292 .map(|metadata| metadata.permissions());
293 atomic_write_file(&self.path, data, permissions.as_ref()).await
294 }
295}
296
297fn normalize_workspaces<I>(workspaces: I) -> Vec<PathBuf>
298where
299 I: IntoIterator<Item = PathBuf>,
300{
301 let mut normalized = Vec::new();
302 for workspace in workspaces {
303 push_workspace(&mut normalized, workspace);
304 }
305
306 normalized
307}
308
309async fn tool_workspaces(meta: &RequestMeta, defaults: &[PathBuf]) -> Vec<PathBuf> {
316 let mut requested = Vec::new();
317
318 if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspace") {
319 push_workspace(&mut requested, workspace);
320 } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspace") {
321 for workspace in extra_workspaces {
322 push_workspace(&mut requested, workspace);
323 }
324 }
325
326 if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspaces") {
327 push_workspace(&mut requested, workspace);
328 } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspaces") {
329 for workspace in extra_workspaces {
330 push_workspace(&mut requested, workspace);
331 }
332 }
333
334 let mut workspaces = Vec::new();
335 if !requested.is_empty() {
336 let resolved_defaults = resolve_workspace_paths(defaults).await;
337 for workspace in requested {
338 if is_within_workspaces(&workspace, &resolved_defaults).await {
339 push_workspace(&mut workspaces, workspace);
340 } else {
341 log::warn!(
342 "ignoring requested workspace {:?} outside the configured workspaces {}",
343 workspace.display().to_string(),
344 format_workspaces(defaults),
345 );
346 }
347 }
348 }
349
350 for workspace in defaults {
351 push_workspace(&mut workspaces, workspace.clone());
352 }
353
354 workspaces
355}
356
357async fn resolve_workspace_paths(workspaces: &[PathBuf]) -> Vec<PathBuf> {
359 let mut resolved = Vec::with_capacity(workspaces.len());
360 for workspace in workspaces {
361 if let Ok(path) = resolve_workspace_path(workspace).await {
362 push_workspace(&mut resolved, path);
363 }
364 }
365
366 resolved
367}
368
369async fn is_within_workspaces(candidate: &Path, resolved_workspaces: &[PathBuf]) -> bool {
371 let Ok(resolved) = resolve_workspace_path(candidate).await else {
372 return false;
373 };
374
375 resolved_workspaces
376 .iter()
377 .any(|root| ensure_path_in_workspace(root, &resolved).is_ok())
378}
379
380fn format_workspaces(workspaces: &[PathBuf]) -> String {
381 if workspaces.is_empty() {
382 return "<none>".to_string();
383 }
384
385 workspaces
386 .iter()
387 .map(|workspace| workspace.display().to_string())
388 .collect::<Vec<_>>()
389 .join(", ")
390}
391
392fn push_workspace(workspaces: &mut Vec<PathBuf>, workspace: PathBuf) {
393 if workspace.as_os_str().is_empty() {
394 return;
395 }
396
397 if !workspaces.iter().any(|existing| existing == &workspace) {
398 workspaces.push(workspace);
399 }
400}
401
402async fn resolve_read_path_in_workspaces(
403 workspaces: &[PathBuf],
404 user_path: &str,
405) -> Result<ResolvedFilePath, BoxError> {
406 let mut errors = Vec::new();
407
408 for workspace in workspaces {
409 match resolve_read_path(workspace, user_path).await {
410 Ok(path) => {
411 return Ok(ResolvedFilePath {
412 workspace: workspace.clone(),
413 path,
414 });
415 }
416 Err(err) => errors.push(format!("{}: {err}", workspace.display())),
417 }
418 }
419
420 Err(workspace_access_error(
421 "Path",
422 "requested_path",
423 user_path,
424 workspaces,
425 errors,
426 ))
427}
428
429async fn resolve_write_path_in_workspaces(
430 workspaces: &[PathBuf],
431 user_path: &str,
432) -> Result<ResolvedFilePath, BoxError> {
433 let requested_path = Path::new(user_path);
434
435 if requested_path.is_relative() {
436 for workspace in workspaces {
437 let candidate_path = workspace.join(requested_path);
438 match tokio::fs::symlink_metadata(&candidate_path).await {
439 Ok(_) => {
440 let path = resolve_write_path(workspace, user_path).await?;
441 return Ok(ResolvedFilePath {
442 workspace: workspace.clone(),
443 path,
444 });
445 }
446 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
447 Err(err) => {
448 return Err(format!(
449 "Failed to inspect file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
450 workspace.display(),
451 user_path,
452 candidate_path.display()
453 )
454 .into());
455 }
456 }
457 }
458 }
459
460 let mut errors = Vec::new();
461 for workspace in workspaces {
462 match resolve_write_path(workspace, user_path).await {
463 Ok(path) => {
464 return Ok(ResolvedFilePath {
465 workspace: workspace.clone(),
466 path,
467 });
468 }
469 Err(err) => errors.push(format!("{}: {err}", workspace.display())),
470 }
471 }
472
473 Err(workspace_access_error(
474 "Path",
475 "requested_path",
476 user_path,
477 workspaces,
478 errors,
479 ))
480}
481
482fn workspace_access_error(
483 subject: &str,
484 request_label: &str,
485 requested_value: &str,
486 workspaces: &[PathBuf],
487 errors: Vec<String>,
488) -> BoxError {
489 let details = if errors.is_empty() {
490 String::new()
491 } else {
492 format!("; errors: {}", errors.join("; "))
493 };
494
495 format!(
496 "{subject} is not accessible from any configured workspace ({request_label}: {}, workspaces: [{}]){}",
497 requested_value,
498 format_workspaces(workspaces),
499 details
500 )
501 .into()
502}
503
504pub async fn resolve_read_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
506 let resolved_workspace = resolve_workspace_path(workspace).await?;
507 let requested_path = Path::new(user_path);
508 let path = workspace.join(requested_path);
509
510 if !path_contains_parent_reference(requested_path) {
511 ensure_path_in_workspace_namespace(workspace, &resolved_workspace, &path)?;
512
513 let resolved_path = tokio::fs::canonicalize(&path)
514 .await
515 .map_err(|err| -> BoxError {
516 format!(
517 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
518 workspace.display(),
519 requested_path.display(),
520 path.display()
521 )
522 .into()
523 })?;
524
525 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
529
530 return Ok(resolved_path);
531 }
532
533 let resolved_path = tokio::fs::canonicalize(&path)
534 .await
535 .map_err(|err| {
536 format!(
537 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
538 workspace.display(),
539 requested_path.display(),
540 path.display()
541 )
542 })?;
543
544 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
545
546 Ok(resolved_path)
547}
548
549pub async fn resolve_write_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
551 let resolved_workspace = resolve_workspace_path(workspace).await?;
552 let path = workspace.join(user_path);
553
554 match tokio::fs::symlink_metadata(&path).await {
555 Ok(meta) => {
556 if meta.file_type().is_symlink() {
557 return Err(format!(
558 "Writing to symbolic links is not allowed (workspace: {}, path: {})",
559 workspace.display(),
560 path.display()
561 )
562 .into());
563 }
564
565 let resolved_path = tokio::fs::canonicalize(&path)
566 .await
567 .map_err(|err| {
568 format!(
569 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
570 workspace.display(),
571 user_path,
572 path.display()
573 )
574 })?;
575 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
576
577 Ok(resolved_path)
578 }
579 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
580 let (existing_ancestor, missing_components) = nearest_existing_ancestor(&path).await?;
581 let resolved_ancestor = tokio::fs::canonicalize(&existing_ancestor)
582 .await
583 .map_err(|err| {
584 format!(
585 "Failed to resolve file path ancestor (workspace: {}, requested_path: {}, ancestor_path: {}): {err}",
586 workspace.display(),
587 user_path,
588 existing_ancestor.display()
589 )
590 })?;
591 ensure_path_in_workspace(&resolved_workspace, &resolved_ancestor)?;
592
593 Ok(missing_components
594 .into_iter()
595 .rev()
596 .fold(resolved_ancestor, |acc, component| acc.join(component)))
597 }
598 Err(err) => Err(format!(
599 "Failed to inspect file path (workspace: {}, path: {}): {err}",
600 workspace.display(),
601 path.display()
602 )
603 .into()),
604 }
605}
606
607async fn resolve_workspace_path(workspace: &Path) -> Result<PathBuf, BoxError> {
608 tokio::fs::canonicalize(workspace).await.map_err(|err| {
609 format!(
610 "Failed to resolve workspace path (workspace: {}): {err}",
611 workspace.display()
612 )
613 .into()
614 })
615}
616
617fn ensure_path_in_workspace(
618 resolved_workspace: &Path,
619 resolved_path: &Path,
620) -> Result<(), BoxError> {
621 if !resolved_path.starts_with(resolved_workspace) {
622 return Err(format!(
623 "Access to paths outside the workspace is not allowed (resolved_workspace: {}, resolved_path: {})",
624 resolved_workspace.display(),
625 resolved_path.display()
626 )
627 .into());
628 }
629
630 Ok(())
631}
632
633fn path_contains_parent_reference(path: &Path) -> bool {
635 path.components()
636 .any(|component| matches!(component, Component::ParentDir))
637}
638
639fn ensure_path_in_workspace_namespace(
641 workspace: &Path,
642 resolved_workspace: &Path,
643 requested_path: &Path,
644) -> Result<(), BoxError> {
645 if requested_path.starts_with(workspace) || requested_path.starts_with(resolved_workspace) {
646 return Ok(());
647 }
648
649 Err(format!(
650 "Access to paths outside the workspace is not allowed (workspace: {}, resolved_workspace: {}, requested_path: {})",
651 workspace.display(),
652 resolved_workspace.display(),
653 requested_path.display()
654 )
655 .into())
656}
657
658pub(crate) fn default_write_encoding() -> String {
660 UTF8_ENCODING.to_string()
661}
662
663pub(crate) fn decode_file_text(bytes: Vec<u8>) -> Result<DecodedFileText, Vec<u8>> {
664 decode_file_text_with_fallback(bytes, platform_text_encoding())
665}
666
667fn decode_file_text_with_fallback(
668 bytes: Vec<u8>,
669 fallback_encoding: Option<&'static Encoding>,
670) -> Result<DecodedFileText, Vec<u8>> {
671 let bytes = match String::from_utf8(bytes) {
673 Ok(text) => {
674 return Ok(DecodedFileText {
675 text,
676 encoding: UTF8_ENCODING.to_string(),
677 });
678 }
679 Err(err) => err.into_bytes(),
680 };
681
682 let Some(encoding) = fallback_encoding else {
683 return Err(bytes);
684 };
685 if encoding.name() == "UTF-8" {
686 return Err(bytes);
687 }
688
689 let text = match text_from_bytes_with_encoding(&bytes, Some(encoding)) {
690 Some(text) => text.into_owned(),
691 None => return Err(bytes),
692 };
693 if !is_text_like(&text) {
694 return Err(bytes);
695 }
696
697 Ok(DecodedFileText {
698 text,
699 encoding: text_encoding_label(encoding),
700 })
701}
702
703pub(crate) fn encode_file_text(
704 content: &str,
705 encoding_label: &str,
706) -> Result<Vec<u8>, FileTextEncodeError> {
707 let encoding =
708 text_encoding_for_label(encoding_label).ok_or(FileTextEncodeError::UnsupportedEncoding)?;
709 let (bytes, _, had_errors) = encoding.encode(content);
710 if had_errors {
711 return Err(FileTextEncodeError::UnmappableCharacters);
712 }
713 Ok(bytes.into_owned())
714}
715
716fn is_text_like(text: &str) -> bool {
717 text.chars()
718 .all(|ch| matches!(ch, '\n' | '\r' | '\t' | '\u{000c}') || !ch.is_control())
719}
720
721pub(crate) fn truncate_inline_text(content: &mut String, max_bytes: usize) -> bool {
725 if content.len() <= max_bytes {
726 return false;
727 }
728
729 let end = crate::grapheme_safe_cutoff(content, max_bytes);
731 let cut = match content[..end].rfind('\n') {
732 Some(idx) if idx > 0 => idx + 1,
734 _ => end,
735 };
736 content.truncate(cut);
737 true
738}
739
740pub(crate) fn has_multiple_hard_links(metadata: &Metadata) -> bool {
745 link_count(metadata) > 1
746}
747
748pub(crate) fn ensure_regular_file(
749 metadata: &Metadata,
750 path: &Path,
751 hard_link_error: &str,
752) -> Result<(), BoxError> {
753 if has_multiple_hard_links(metadata) {
754 return Err(format!("{} (path: {})", hard_link_error, path.display()).into());
755 }
756
757 if !metadata.is_file() {
758 return Err(format!(
759 "Path does not point to a regular file (path: {})",
760 path.display()
761 )
762 .into());
763 }
764
765 Ok(())
766}
767
768pub(crate) fn ensure_file_size_within_limit(
769 metadata: &Metadata,
770 path: &Path,
771 max_size_bytes: u64,
772) -> Result<(), BoxError> {
773 if metadata.len() > max_size_bytes {
774 return Err(format!(
775 "File size {} exceeds maximum allowed size of {} bytes (path: {})",
776 metadata.len(),
777 max_size_bytes,
778 path.display()
779 )
780 .into());
781 }
782
783 Ok(())
784}
785
786#[cfg(unix)]
787fn link_count(metadata: &Metadata) -> u64 {
788 use std::os::unix::fs::MetadataExt;
789 metadata.nlink()
790}
791
792#[cfg(windows)]
793fn link_count(_metadata: &Metadata) -> u64 {
794 1
798}
799
800#[cfg(not(any(unix, windows)))]
801fn link_count(_metadata: &Metadata) -> u64 {
802 1
803}
804
805pub async fn atomic_write_file(
807 target_path: &Path,
808 data: &[u8],
809 existing_permissions: Option<&Permissions>,
810) -> Result<(), BoxError> {
811 let temp_path =
812 write_temp_file_for_atomic_replace(target_path, data, existing_permissions).await?;
813
814 if let Err(err) = commit_atomic_replace(&temp_path, target_path).await {
815 let _ = tokio::fs::remove_file(&temp_path).await;
816 return Err(err);
817 }
818
819 Ok(())
820}
821
822pub(crate) async fn write_temp_file_for_atomic_replace(
823 target_path: &Path,
824 data: &[u8],
825 existing_permissions: Option<&Permissions>,
826) -> Result<PathBuf, BoxError> {
827 for _ in 0..16 {
828 let temp_path = atomic_temp_path(target_path)?;
829 let mut file = match tokio::fs::OpenOptions::new()
830 .create_new(true)
831 .write(true)
832 .open(&temp_path)
833 .await
834 {
835 Ok(file) => file,
836 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
837 Err(err) => {
838 return Err(format!(
839 "Failed to create temporary file (target_path: {}, temp_path: {}): {err}",
840 target_path.display(),
841 temp_path.display()
842 )
843 .into());
844 }
845 };
846
847 let write_result = async {
848 file.write_all(data)
849 .await
850 .map_err(|err| {
851 format!(
852 "Failed to write temporary file (target_path: {}, temp_path: {}): {err}",
853 target_path.display(),
854 temp_path.display()
855 )
856 })?;
857
858 if let Some(permissions) = existing_permissions {
859 tokio::fs::set_permissions(&temp_path, permissions.clone())
860 .await
861 .map_err(|err| {
862 format!(
863 "Failed to apply file permissions (target_path: {}, temp_path: {}): {err}",
864 target_path.display(),
865 temp_path.display()
866 )
867 })?;
868 }
869
870 file.sync_all()
871 .await
872 .map_err(|err| {
873 format!(
874 "Failed to sync temporary file (target_path: {}, temp_path: {}): {err}",
875 target_path.display(),
876 temp_path.display()
877 )
878 })?;
879
880 Ok::<(), BoxError>(())
881 }
882 .await;
883 drop(file);
884
885 if let Err(err) = write_result {
886 let _ = tokio::fs::remove_file(&temp_path).await;
887 return Err(err);
888 }
889
890 return Ok(temp_path);
891 }
892
893 Err(format!(
894 "Failed to allocate unique temporary file for atomic write (target_path: {})",
895 target_path.display()
896 )
897 .into())
898}
899
900pub(crate) async fn commit_atomic_replace(
901 temp_path: &Path,
902 target_path: &Path,
903) -> Result<(), BoxError> {
904 tokio::fs::rename(temp_path, target_path)
905 .await
906 .map_err(|err| {
907 format!(
908 "Failed to atomically replace file (temp_path: {}, target_path: {}): {err}",
909 temp_path.display(),
910 target_path.display()
911 )
912 .into()
913 })
914}
915
916fn atomic_temp_path(target_path: &Path) -> Result<PathBuf, BoxError> {
917 let parent = target_path.parent().ok_or_else(|| {
918 format!(
919 "Failed to determine parent directory for write target (target_path: {})",
920 target_path.display()
921 )
922 })?;
923 let file_name = target_path.file_name().ok_or_else(|| {
924 format!(
925 "Failed to determine file name for write target (target_path: {})",
926 target_path.display()
927 )
928 })?;
929
930 let mut temp_name = OsString::from(".");
931 temp_name.push(file_name);
932 temp_name.push(format!(".anda-tmp-{:016x}", rand::random::<u64>()));
933
934 Ok(parent.join(temp_name))
935}
936
937async fn nearest_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec<OsString>), BoxError> {
939 let mut current = path.to_path_buf();
940 let mut missing_components = Vec::new();
941
942 loop {
943 match tokio::fs::symlink_metadata(¤t).await {
944 Ok(_) => return Ok((current, missing_components)),
945 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
946 let file_name = current.file_name().ok_or_else(|| {
947 format!(
948 "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
949 path.display(),
950 current.display()
951 )
952 })?;
953 missing_components.push(file_name.to_os_string());
954 current = current
955 .parent()
956 .ok_or_else(|| {
957 format!(
958 "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
959 path.display(),
960 current.display()
961 )
962 })?
963 .to_path_buf();
964 }
965 Err(err) => {
966 return Err(format!(
967 "Failed to inspect file path while resolving ancestor (requested_path: {}, current_path: {}): {err}",
968 path.display(),
969 current.display()
970 )
971 .into())
972 }
973 }
974 }
975}
976
977pub(crate) fn normalize_relative_path(path: &Path) -> String {
978 let value = path
979 .to_string_lossy()
980 .replace(std::path::MAIN_SEPARATOR, "/");
981 if value.is_empty() {
982 ".".to_string()
983 } else {
984 value
985 }
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use anda_core::RequestMeta;
992 use serde_json::json;
993
994 fn temp_dir(name: &str) -> PathBuf {
995 std::env::temp_dir().join(format!("anda-fs-{name}-{:016x}", rand::random::<u64>()))
996 }
997
998 #[test]
999 fn fs_tools_form_one_capability_group() {
1000 use crate::context::BaseCtx;
1001 use anda_core::ToolSet;
1002 use std::sync::Arc;
1003
1004 let workspace = PathBuf::from("/tmp/anda-fs-group");
1005 let mut tools = ToolSet::<BaseCtx>::new();
1006 tools
1007 .add(Arc::new(ReadFileTool::new(workspace.clone())))
1008 .unwrap();
1009 tools
1010 .add(Arc::new(WriteFileTool::new(workspace.clone())))
1011 .unwrap();
1012 tools
1013 .add(Arc::new(EditFileTool::new(workspace.clone())))
1014 .unwrap();
1015 tools.add(Arc::new(SearchFileTool::new(workspace))).unwrap();
1016
1017 let groups = tools.groups();
1018 assert_eq!(groups.len(), 1);
1019 assert_eq!(groups[0].id, FS_TOOL_GROUP_ID);
1020 assert_eq!(
1022 groups[0].members,
1023 vec![
1024 "edit_file".to_string(),
1025 "read_file".to_string(),
1026 "search_file".to_string(),
1027 "write_file".to_string(),
1028 ]
1029 );
1030 assert!(groups[0].instructions.is_some());
1031 }
1032
1033 #[test]
1034 fn workspace_helpers_normalize_dedupe_and_report_empty_sets() {
1035 let first = PathBuf::from("/tmp/one");
1036 let second = PathBuf::from("/tmp/two");
1037
1038 assert_eq!(
1039 normalize_workspaces(vec![
1040 PathBuf::new(),
1041 first.clone(),
1042 first.clone(),
1043 second.clone()
1044 ]),
1045 vec![first.clone(), second.clone()]
1046 );
1047 assert_eq!(format_workspaces(&[]), "<none>");
1048 assert_eq!(
1049 workspace_access_error("Path", "requested_path", "file.txt", &[], Vec::new())
1050 .to_string(),
1051 "Path is not accessible from any configured workspace (requested_path: file.txt, workspaces: [<none>])"
1052 );
1053 }
1054
1055 #[tokio::test(flavor = "current_thread")]
1056 async fn tool_workspaces_only_accepts_requests_inside_configured_roots() {
1057 let root = temp_dir("tool_workspaces");
1058 let configured = root.join("configured");
1059 let nested = configured.join("nested");
1060 let outside = root.join("outside");
1061 tokio::fs::create_dir_all(&nested).await.unwrap();
1062 tokio::fs::create_dir_all(&outside).await.unwrap();
1063
1064 assert_eq!(
1066 tool_workspaces(&RequestMeta::default(), std::slice::from_ref(&configured)).await,
1067 vec![configured.clone()]
1068 );
1069
1070 let mut meta = RequestMeta::default();
1072 meta.extra
1073 .insert("workspace".to_string(), json!([nested, "", nested]));
1074 assert_eq!(
1075 tool_workspaces(&meta, std::slice::from_ref(&configured)).await,
1076 vec![nested.clone(), configured.clone()]
1077 );
1078
1079 let mut meta = RequestMeta::default();
1081 meta.extra.insert("workspace".to_string(), json!(outside));
1082 assert_eq!(
1083 tool_workspaces(&meta, std::slice::from_ref(&configured)).await,
1084 vec![configured.clone()]
1085 );
1086
1087 let mut meta = RequestMeta::default();
1089 meta.extra.insert("workspaces".to_string(), json!("/"));
1090 assert_eq!(
1091 tool_workspaces(&meta, std::slice::from_ref(&configured)).await,
1092 vec![configured.clone()]
1093 );
1094
1095 let mut meta = RequestMeta::default();
1097 meta.extra.insert(
1098 "workspace".to_string(),
1099 json!(configured.join("does-not-exist")),
1100 );
1101 assert_eq!(
1102 tool_workspaces(&meta, std::slice::from_ref(&configured)).await,
1103 vec![configured.clone()]
1104 );
1105
1106 let _ = tokio::fs::remove_dir_all(&root).await;
1107 }
1108
1109 #[test]
1110 fn file_text_encoding_decodes_legacy_text_and_rejects_binary() {
1111 let gbk = vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n'];
1112
1113 let decoded = decode_file_text_with_fallback(gbk.clone(), Some(encoding_rs::GBK)).unwrap();
1114 assert_eq!(
1115 decoded,
1116 DecodedFileText {
1117 text: "中文.txt\n".to_string(),
1118 encoding: "gbk".to_string(),
1119 }
1120 );
1121
1122 let utf8 = decode_file_text_with_fallback(
1123 "中文.txt\n".as_bytes().to_vec(),
1124 Some(encoding_rs::GBK),
1125 )
1126 .unwrap();
1127 assert_eq!(utf8.text, "中文.txt\n");
1128 assert_eq!(utf8.encoding, UTF8_ENCODING);
1129
1130 let binary = vec![0xff, 0x00, 0x81, 0x7f];
1131 assert_eq!(
1132 decode_file_text_with_fallback(binary.clone(), Some(encoding_rs::GBK)).unwrap_err(),
1133 binary
1134 );
1135 }
1136
1137 #[test]
1138 fn file_text_encoding_encodes_legacy_text() {
1139 let gbk = vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n'];
1140
1141 assert_eq!(encode_file_text("中文.txt\n", "gbk").unwrap(), gbk);
1142 assert_eq!(
1143 encode_file_text("hello", "utf-8").unwrap(),
1144 b"hello".to_vec()
1145 );
1146 assert_eq!(
1147 encode_file_text("hello", "not-an-encoding").unwrap_err(),
1148 FileTextEncodeError::UnsupportedEncoding
1149 );
1150 }
1151
1152 #[test]
1153 fn truncate_inline_text_prefers_line_then_char_boundaries() {
1154 let mut text = "short".to_string();
1155 assert!(!truncate_inline_text(&mut text, 10));
1156 assert_eq!(text, "short");
1157
1158 let mut text = "line one\nline two\nline three".to_string();
1159 assert!(truncate_inline_text(&mut text, 20));
1160 assert_eq!(text, "line one\nline two\n");
1161
1162 let mut text = "中文内容没有换行".to_string();
1164 assert!(truncate_inline_text(&mut text, 10));
1165 assert_eq!(text, "中文内");
1166
1167 let mut text = "\nabcdefghijklmnop".to_string();
1169 assert!(truncate_inline_text(&mut text, 8));
1170 assert_eq!(text, "\nabcdefg");
1171
1172 let family = "👨👩👧👦";
1176 let mut text = family.repeat(3); assert!(truncate_inline_text(&mut text, 60));
1178 assert_eq!(text, family.repeat(2));
1179 }
1180
1181 #[test]
1182 fn file_metadata_guards_reject_non_regular_large_and_hardlinked_files() {
1183 let root = temp_dir("metadata");
1184 std::fs::create_dir_all(&root).unwrap();
1185 let file = root.join("file.txt");
1186 std::fs::write(&file, b"abcd").unwrap();
1187
1188 let file_meta = std::fs::metadata(&file).unwrap();
1189 ensure_file_size_within_limit(&file_meta, &file, 4).unwrap();
1190 assert!(
1191 ensure_file_size_within_limit(&file_meta, &file, 3)
1192 .unwrap_err()
1193 .to_string()
1194 .contains("exceeds maximum")
1195 );
1196
1197 let dir_meta = std::fs::symlink_metadata(&root).unwrap();
1198 assert!(
1199 ensure_regular_file(&dir_meta, &root, "hard links blocked")
1200 .unwrap_err()
1201 .to_string()
1202 .contains("Path does not point to a regular file")
1203 || ensure_regular_file(&dir_meta, &root, "hard links blocked")
1204 .unwrap_err()
1205 .to_string()
1206 .contains("hard links blocked")
1207 );
1208
1209 #[cfg(unix)]
1210 {
1211 let link = root.join("link.txt");
1212 std::fs::hard_link(&file, &link).unwrap();
1213 let linked_meta = std::fs::metadata(&file).unwrap();
1214 assert!(has_multiple_hard_links(&linked_meta));
1215 assert!(
1216 ensure_regular_file(&linked_meta, &file, "hard links blocked")
1217 .unwrap_err()
1218 .to_string()
1219 .contains("hard links blocked")
1220 );
1221 }
1222
1223 let _ = std::fs::remove_dir_all(root);
1224 }
1225
1226 #[tokio::test(flavor = "current_thread")]
1227 async fn resolve_helpers_cover_parent_paths_missing_tails_and_errors() {
1228 let root = temp_dir("resolve");
1229 tokio::fs::create_dir_all(root.join("dir")).await.unwrap();
1230 tokio::fs::write(root.join("dir/file.txt"), b"ok")
1231 .await
1232 .unwrap();
1233
1234 let parent_read = resolve_read_path(&root, "dir/../dir/file.txt")
1235 .await
1236 .unwrap();
1237 assert_eq!(
1238 parent_read,
1239 tokio::fs::canonicalize(root.join("dir/file.txt"))
1240 .await
1241 .unwrap()
1242 );
1243
1244 let canonical_root = tokio::fs::canonicalize(&root).await.unwrap();
1245 let write_path = resolve_write_path(&root, "new/nested/file.txt")
1246 .await
1247 .unwrap();
1248 assert_eq!(write_path, canonical_root.join("new/nested/file.txt"));
1249
1250 let selected = resolve_write_path_in_workspaces(
1251 &[root.join("missing"), root.clone()],
1252 "new/nested/file.txt",
1253 )
1254 .await
1255 .unwrap();
1256 assert_eq!(selected.workspace, root);
1257 assert!(selected.path.ends_with("new/nested/file.txt"));
1258
1259 let read_err = resolve_read_path_in_workspaces(&[], "missing.txt")
1260 .await
1261 .unwrap_err();
1262 assert!(read_err.to_string().contains("workspaces: [<none>]"));
1263
1264 let missing_workspace = resolve_workspace_path(Path::new("/definitely/missing/anda"))
1265 .await
1266 .unwrap_err();
1267 assert!(
1268 missing_workspace
1269 .to_string()
1270 .contains("Failed to resolve workspace path")
1271 );
1272
1273 assert!(path_contains_parent_reference(Path::new("a/../b")));
1274 assert!(!path_contains_parent_reference(Path::new("a/b")));
1275 assert!(
1276 ensure_path_in_workspace_namespace(
1277 Path::new("/tmp/work"),
1278 Path::new("/tmp/work"),
1279 Path::new("/tmp/other/file.txt"),
1280 )
1281 .unwrap_err()
1282 .to_string()
1283 .contains("outside the workspace")
1284 );
1285
1286 let _ = tokio::fs::remove_dir_all(selected.workspace).await;
1287 }
1288
1289 #[tokio::test(flavor = "current_thread")]
1290 async fn atomic_write_helpers_commit_cleanup_and_path_formatting() {
1291 let root = temp_dir("atomic");
1292 tokio::fs::create_dir_all(&root).await.unwrap();
1293 let target = root.join("file.txt");
1294
1295 atomic_write_file(&target, b"first", None).await.unwrap();
1296 assert_eq!(tokio::fs::read(&target).await.unwrap(), b"first");
1297
1298 let permissions = tokio::fs::metadata(&target).await.unwrap().permissions();
1299 atomic_write_file(&target, b"second", Some(&permissions))
1300 .await
1301 .unwrap();
1302 assert_eq!(tokio::fs::read(&target).await.unwrap(), b"second");
1303
1304 let temp = write_temp_file_for_atomic_replace(&target, b"third", None)
1305 .await
1306 .unwrap();
1307 assert!(
1308 temp.file_name()
1309 .unwrap()
1310 .to_string_lossy()
1311 .contains(".anda-tmp-")
1312 );
1313 commit_atomic_replace(&temp, &target).await.unwrap();
1314 assert_eq!(tokio::fs::read(&target).await.unwrap(), b"third");
1315
1316 let missing_temp = root.join("missing-temp");
1317 assert!(
1318 commit_atomic_replace(&missing_temp, &target)
1319 .await
1320 .unwrap_err()
1321 .to_string()
1322 .contains("Failed to atomically replace file")
1323 );
1324 assert!(
1325 atomic_write_file(&root, b"cannot replace a directory", None)
1326 .await
1327 .unwrap_err()
1328 .to_string()
1329 .contains("Failed to atomically replace file")
1330 );
1331 assert!(
1332 write_temp_file_for_atomic_replace(&root.join("missing/file.txt"), b"bad", None)
1333 .await
1334 .unwrap_err()
1335 .to_string()
1336 .contains("Failed to create temporary file")
1337 );
1338 assert!(
1339 write_temp_file_for_atomic_replace(Path::new(""), b"bad", None)
1340 .await
1341 .unwrap_err()
1342 .to_string()
1343 .contains("Failed to determine")
1344 );
1345
1346 let (ancestor, missing) = nearest_existing_ancestor(&root.join("a/b/c.txt"))
1347 .await
1348 .unwrap();
1349 assert_eq!(ancestor, root);
1350 assert_eq!(missing.len(), 3);
1351 assert!(
1352 nearest_existing_ancestor(Path::new(""))
1353 .await
1354 .unwrap_err()
1355 .to_string()
1356 .contains("outside the workspace")
1357 );
1358 assert_eq!(normalize_relative_path(Path::new("")), ".");
1359 assert_eq!(normalize_relative_path(Path::new("a/b")), "a/b");
1360 assert_eq!(default_write_encoding(), UTF8_ENCODING);
1361
1362 let _ = tokio::fs::remove_dir_all(root).await;
1363 }
1364
1365 #[tokio::test(flavor = "current_thread")]
1366 async fn deterministic_error_branches_for_read_and_metadata_guards() {
1367 let root = temp_dir("fs-errors");
1368 tokio::fs::create_dir_all(&root).await.unwrap();
1369
1370 let err = resolve_read_path(&root, "missing/../missing.txt")
1371 .await
1372 .unwrap_err();
1373 assert!(err.to_string().contains("Failed to resolve file path"));
1374
1375 #[cfg(unix)]
1376 {
1377 use std::os::unix::fs::symlink;
1378
1379 let link = root.join("link");
1380 symlink(root.join("missing-target"), &link).unwrap();
1381 let meta = std::fs::symlink_metadata(&link).unwrap();
1382 assert!(
1383 ensure_regular_file(&meta, &link, "hard links blocked")
1384 .unwrap_err()
1385 .to_string()
1386 .contains("Path does not point to a regular file")
1387 );
1388 }
1389
1390 let _ = tokio::fs::remove_dir_all(root).await;
1391 }
1392}