Skip to main content

anda_engine/extension/
fs.rs

1//! Workspace-scoped filesystem tool support.
2//!
3//! This module contains shared path resolution, text decoding/encoding, size
4//! limits, and atomic write helpers used by the read, write, search, and edit
5//! filesystem tools. Public tool structs are re-exported from the submodules.
6//!
7//! All four tools report the same capability group via [`fs_tool_group_info`],
8//! so the discovery layer presents them to the model as one workspace bundle.
9
10use 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
33/// Stable id of the filesystem workspace capability group.
34pub const FS_TOOL_GROUP_ID: &str = "fs_workspace";
35
36/// Returns the shared [`ToolGroupInfo`] for the filesystem workspace tools.
37///
38/// Every `read_file` / `search_file` / `edit_file` / `write_file` tool reports
39/// this so the registry presents them as one bundle. The registry fills in the
40/// member list from the tools actually registered.
41pub 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
54/// Maximum bytes of file content returned inline in a tool response. Larger
55/// content is truncated so a single read cannot flood the model context.
56pub(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/// Sandboxed workspace roots for one tool call, in priority order.
93///
94/// Built once per call from the tool's configured roots plus the request's
95/// untrusted narrowing hints ([`RequestMeta.extra`]), so a request can
96/// prioritize a subdirectory of a configured root but never escape it. Every
97/// filesystem operation the workspace tools perform goes through this scope:
98/// multi-root resolution order, symlink and hard-link re-checks, size limits,
99/// and error text all live behind it.
100#[derive(Debug, Clone)]
101pub(crate) struct WorkspaceScope {
102    workspaces: Vec<PathBuf>,
103}
104
105/// An existing regular file resolved inside a workspace.
106///
107/// Produced by [`WorkspaceScope::open_read`] / [`WorkspaceScope::open_edit`]
108/// with the regular-file, hard-link, and size-limit checks already applied.
109#[derive(Debug)]
110pub(crate) struct ReadTarget {
111    pub(crate) workspace: PathBuf,
112    pub(crate) path: PathBuf,
113    pub(crate) metadata: Metadata,
114}
115
116/// A write destination resolved inside a workspace.
117///
118/// Produced by [`WorkspaceScope::open_write`]; `existing` carries the current
119/// file's metadata when the destination already exists (its permissions are
120/// preserved by [`WriteTarget::write_atomic`]).
121#[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    /// Resolves the workspace roots for one tool call from the configured
131    /// defaults and the request's narrowing hints.
132    pub(crate) async fn for_call(meta: &RequestMeta, defaults: &[PathBuf]) -> Self {
133        Self {
134            workspaces: tool_workspaces(meta, defaults).await,
135        }
136    }
137
138    /// The workspace roots in priority order.
139    pub(crate) fn roots(&self) -> &[PathBuf] {
140        &self.workspaces
141    }
142
143    /// Consumes the scope and returns the highest-priority root, if any.
144    pub(crate) fn into_primary(self) -> Option<PathBuf> {
145        self.workspaces.into_iter().next()
146    }
147
148    /// Human-readable list of the roots for descriptions and error text.
149    pub(crate) fn display(&self) -> String {
150        format_workspaces(&self.workspaces)
151    }
152
153    /// Opens an existing file for reading.
154    ///
155    /// Resolves `user_path` against the roots in priority order (following the
156    /// read rules: the target must exist and canonicalize inside a root), then
157    /// enforces the regular-file, hard-link, and size-limit checks.
158    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    /// Opens an existing file for in-place editing.
176    ///
177    /// Resolves `user_path` with the write rules (symlink targets are refused)
178    /// but requires the destination to already exist, then enforces the
179    /// regular-file, hard-link, and size-limit checks.
180    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    /// Opens a write destination, existing or not.
213    ///
214    /// Resolves `user_path` with the write rules; when the destination exists
215    /// it must be a regular, singly-linked file whose permissions are carried
216    /// into [`WriteTarget::write_atomic`].
217    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    /// Atomically replaces the file's content, preserving its permissions.
264    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    /// Atomically writes the destination.
271    ///
272    /// For a new file the missing parent directories are created first and the
273    /// file gets default permissions; an existing file keeps its permissions.
274    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
309/// Resolves the workspace roots for a tool call.
310///
311/// `RequestMeta.extra` is flattened straight off the RPC body, so a requested workspace is
312/// caller-controlled and untrusted. A request may only ever *narrow* the configured roots by
313/// prioritizing a subdirectory of one of them; a requested root that does not resolve inside a
314/// configured root is dropped, so the configured roots always bound what the tool can reach.
315async 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
357/// Canonicalizes each workspace, dropping the ones that cannot be resolved.
358async 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
369/// Returns true when `candidate` canonicalizes inside one of the already-resolved roots.
370async 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
504/// Resolves an existing read target reachable from the workspace namespace.
505pub 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        // The requested path itself stays inside the workspace, but it may pass through a
526        // symbolic link that resolves outside of it. Re-check the canonicalized target so a
527        // workspace-local symlink cannot be used to read arbitrary host files.
528        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
549/// Resolves a write target inside the workspace, even when the destination does not yet exist.
550pub 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
633/// Returns true when the requested path contains a parent directory traversal.
634fn path_contains_parent_reference(path: &Path) -> bool {
635    path.components()
636        .any(|component| matches!(component, Component::ParentDir))
637}
638
639/// Ensures the requested path stays within the workspace namespace before following symlinks.
640fn 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
658/// Returns the default encoding used for file writes.
659pub(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    // Take ownership on success so valid UTF-8 content is not copied.
672    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
721/// Truncates `content` to at most `max_bytes`, preferring a line boundary and falling back to a
722/// grapheme-cluster boundary so a multibyte character or emoji cluster is never split. Returns true
723/// when content was cut.
724pub(crate) fn truncate_inline_text(content: &mut String, max_bytes: usize) -> bool {
725    if content.len() <= max_bytes {
726        return false;
727    }
728
729    // Grapheme-cluster-safe byte cutoff within the budget (shared with truncate_utf8_to_max_bytes).
730    let end = crate::grapheme_safe_cutoff(content, max_bytes);
731    let cut = match content[..end].rfind('\n') {
732        // Keep whole lines when possible; a single oversized line is cut at `end`.
733        Some(idx) if idx > 0 => idx + 1,
734        _ => end,
735    };
736    content.truncate(cut);
737    true
738}
739
740/// Returns true when a file has multiple hard links.
741///
742/// Multiple links can allow path-based workspace guards to be bypassed by
743/// linking a workspace path to external sensitive content.
744pub(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    // Rust stable does not currently expose a portable, stable Windows hard-link
795    // count API on `std::fs::Metadata`. Returning 1 avoids false positive blocks
796    // and keeps Windows builds stable until a supported API is available.
797    1
798}
799
800#[cfg(not(any(unix, windows)))]
801fn link_count(_metadata: &Metadata) -> u64 {
802    1
803}
804
805/// Atomically writes data to a file by first writing to a temporary file and then renaming it into place.
806pub 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
937/// Finds the nearest existing path component and returns the missing tail components.
938async 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(&current).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        // All four registered tools land in the group, sorted by name.
1021        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        // No request metadata: the configured roots are used as-is.
1065        assert_eq!(
1066            tool_workspaces(&RequestMeta::default(), std::slice::from_ref(&configured)).await,
1067            vec![configured.clone()]
1068        );
1069
1070        // A request may narrow to a subdirectory of a configured root, which then takes priority.
1071        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        // A request outside the configured roots is dropped, not prioritized.
1080        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        // Filesystem root, the widest possible escape, is likewise rejected.
1088        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        // A path that does not exist cannot be resolved and is dropped.
1096        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        // A single oversized line is cut at a grapheme boundary instead of dropped.
1163        let mut text = "中文内容没有换行".to_string();
1164        assert!(truncate_inline_text(&mut text, 10));
1165        assert_eq!(text, "中文内");
1166
1167        // A leading newline does not produce an empty result.
1168        let mut text = "\nabcdefghijklmnop".to_string();
1169        assert!(truncate_inline_text(&mut text, 8));
1170        assert_eq!(text, "\nabcdefg");
1171
1172        // A multi-codepoint grapheme cluster (family emoji joined by ZWJ, 25 bytes) on a single
1173        // oversized line is never split: a budget landing mid-cluster backs off to the previous
1174        // cluster boundary.
1175        let family = "👨‍👩‍👧‍👦";
1176        let mut text = family.repeat(3); // 75 bytes, no newline
1177        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}