Skip to main content

anda_engine/extension/
fs.rs

1use anda_core::{BoxError, RequestMeta};
2use std::{
3    ffi::OsString,
4    fs::{Metadata, Permissions},
5    path::{Component, Path, PathBuf},
6};
7use tokio::io::AsyncWriteExt;
8
9mod edit;
10mod read;
11mod search;
12mod write;
13
14pub use edit::*;
15pub use read::*;
16pub use search::*;
17pub use write::*;
18
19pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
20
21pub(crate) const UTF8_ENCODING: &str = "utf8";
22pub(crate) const BASE64_ENCODING: &str = "base64";
23
24#[derive(Debug, Clone)]
25pub(crate) struct ResolvedFilePath {
26    pub(crate) workspace: PathBuf,
27    pub(crate) path: PathBuf,
28}
29
30pub(crate) fn normalize_workspaces<I>(workspaces: I) -> Vec<PathBuf>
31where
32    I: IntoIterator<Item = PathBuf>,
33{
34    let mut normalized = Vec::new();
35    for workspace in workspaces {
36        push_workspace(&mut normalized, workspace);
37    }
38
39    normalized
40}
41
42pub(crate) fn tool_workspaces(meta: &RequestMeta, defaults: &[PathBuf]) -> Vec<PathBuf> {
43    let mut workspaces = Vec::new();
44
45    if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspace") {
46        push_workspace(&mut workspaces, workspace);
47    } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspace") {
48        for workspace in extra_workspaces {
49            push_workspace(&mut workspaces, workspace);
50        }
51    }
52
53    if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspaces") {
54        push_workspace(&mut workspaces, workspace);
55    } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspaces") {
56        for workspace in extra_workspaces {
57            push_workspace(&mut workspaces, workspace);
58        }
59    }
60
61    for workspace in defaults {
62        push_workspace(&mut workspaces, workspace.clone());
63    }
64
65    workspaces
66}
67
68pub(crate) fn format_workspaces(workspaces: &[PathBuf]) -> String {
69    if workspaces.is_empty() {
70        return "<none>".to_string();
71    }
72
73    workspaces
74        .iter()
75        .map(|workspace| workspace.display().to_string())
76        .collect::<Vec<_>>()
77        .join(", ")
78}
79
80fn push_workspace(workspaces: &mut Vec<PathBuf>, workspace: PathBuf) {
81    if workspace.as_os_str().is_empty() {
82        return;
83    }
84
85    if !workspaces.iter().any(|existing| existing == &workspace) {
86        workspaces.push(workspace);
87    }
88}
89
90pub(crate) async fn resolve_read_path_in_workspaces(
91    workspaces: &[PathBuf],
92    user_path: &str,
93) -> Result<ResolvedFilePath, BoxError> {
94    let mut errors = Vec::new();
95
96    for workspace in workspaces {
97        match resolve_read_path(workspace, user_path).await {
98            Ok(path) => {
99                return Ok(ResolvedFilePath {
100                    workspace: workspace.clone(),
101                    path,
102                });
103            }
104            Err(err) => errors.push(format!("{}: {err}", workspace.display())),
105        }
106    }
107
108    Err(workspace_access_error(
109        "Path",
110        "requested_path",
111        user_path,
112        workspaces,
113        errors,
114    ))
115}
116
117pub(crate) async fn resolve_write_path_in_workspaces(
118    workspaces: &[PathBuf],
119    user_path: &str,
120) -> Result<ResolvedFilePath, BoxError> {
121    let requested_path = Path::new(user_path);
122
123    if requested_path.is_relative() {
124        for workspace in workspaces {
125            let candidate_path = workspace.join(requested_path);
126            match tokio::fs::symlink_metadata(&candidate_path).await {
127                Ok(_) => {
128                    let path = resolve_write_path(workspace, user_path).await?;
129                    return Ok(ResolvedFilePath {
130                        workspace: workspace.clone(),
131                        path,
132                    });
133                }
134                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
135                Err(err) => {
136                    return Err(format!(
137                        "Failed to inspect file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
138                        workspace.display(),
139                        user_path,
140                        candidate_path.display()
141                    )
142                    .into());
143                }
144            }
145        }
146    }
147
148    let mut errors = Vec::new();
149    for workspace in workspaces {
150        match resolve_write_path(workspace, user_path).await {
151            Ok(path) => {
152                return Ok(ResolvedFilePath {
153                    workspace: workspace.clone(),
154                    path,
155                });
156            }
157            Err(err) => errors.push(format!("{}: {err}", workspace.display())),
158        }
159    }
160
161    Err(workspace_access_error(
162        "Path",
163        "requested_path",
164        user_path,
165        workspaces,
166        errors,
167    ))
168}
169
170pub(crate) fn workspace_access_error(
171    subject: &str,
172    request_label: &str,
173    requested_value: &str,
174    workspaces: &[PathBuf],
175    errors: Vec<String>,
176) -> BoxError {
177    let details = if errors.is_empty() {
178        String::new()
179    } else {
180        format!("; errors: {}", errors.join("; "))
181    };
182
183    format!(
184        "{subject} is not accessible from any configured workspace ({request_label}: {}, workspaces: [{}]){}",
185        requested_value,
186        format_workspaces(workspaces),
187        details
188    )
189    .into()
190}
191
192/// Resolves an existing read target reachable from the workspace namespace.
193pub async fn resolve_read_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
194    let resolved_workspace = resolve_workspace_path(workspace).await?;
195    let requested_path = Path::new(user_path);
196    let path = workspace.join(requested_path);
197
198    if !path_contains_parent_reference(requested_path) {
199        ensure_path_in_workspace_namespace(workspace, &resolved_workspace, &path)?;
200
201        return tokio::fs::canonicalize(&path)
202            .await
203            .map_err(|err| {
204                format!(
205                    "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
206                    workspace.display(),
207                    requested_path.display(),
208                    path.display()
209                )
210                .into()
211            });
212    }
213
214    let resolved_path = tokio::fs::canonicalize(&path)
215        .await
216        .map_err(|err| {
217            format!(
218                "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
219                workspace.display(),
220                requested_path.display(),
221                path.display()
222            )
223        })?;
224
225    ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
226
227    Ok(resolved_path)
228}
229
230/// Resolves a write target inside the workspace, even when the destination does not yet exist.
231pub async fn resolve_write_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
232    let resolved_workspace = resolve_workspace_path(workspace).await?;
233    let path = workspace.join(user_path);
234
235    match tokio::fs::symlink_metadata(&path).await {
236        Ok(meta) => {
237            if meta.file_type().is_symlink() {
238                return Err(format!(
239                    "Writing to symbolic links is not allowed (workspace: {}, path: {})",
240                    workspace.display(),
241                    path.display()
242                )
243                .into());
244            }
245
246            let resolved_path = tokio::fs::canonicalize(&path)
247                .await
248                .map_err(|err| {
249                    format!(
250                        "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
251                        workspace.display(),
252                        user_path,
253                        path.display()
254                    )
255                })?;
256            ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
257
258            Ok(resolved_path)
259        }
260        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
261            let (existing_ancestor, missing_components) = nearest_existing_ancestor(&path).await?;
262            let resolved_ancestor = tokio::fs::canonicalize(&existing_ancestor)
263                .await
264                .map_err(|err| {
265                    format!(
266                        "Failed to resolve file path ancestor (workspace: {}, requested_path: {}, ancestor_path: {}): {err}",
267                        workspace.display(),
268                        user_path,
269                        existing_ancestor.display()
270                    )
271                })?;
272            ensure_path_in_workspace(&resolved_workspace, &resolved_ancestor)?;
273
274            Ok(missing_components
275                .into_iter()
276                .rev()
277                .fold(resolved_ancestor, |acc, component| acc.join(component)))
278        }
279        Err(err) => Err(format!(
280            "Failed to inspect file path (workspace: {}, path: {}): {err}",
281            workspace.display(),
282            path.display()
283        )
284        .into()),
285    }
286}
287
288pub(crate) async fn resolve_workspace_path(workspace: &Path) -> Result<PathBuf, BoxError> {
289    tokio::fs::canonicalize(workspace).await.map_err(|err| {
290        format!(
291            "Failed to resolve workspace path (workspace: {}): {err}",
292            workspace.display()
293        )
294        .into()
295    })
296}
297
298pub(crate) fn ensure_path_in_workspace(
299    resolved_workspace: &Path,
300    resolved_path: &Path,
301) -> Result<(), BoxError> {
302    if !resolved_path.starts_with(resolved_workspace) {
303        return Err(format!(
304            "Access to paths outside the workspace is not allowed (resolved_workspace: {}, resolved_path: {})",
305            resolved_workspace.display(),
306            resolved_path.display()
307        )
308        .into());
309    }
310
311    Ok(())
312}
313
314/// Returns true when the requested path contains a parent directory traversal.
315pub(crate) fn path_contains_parent_reference(path: &Path) -> bool {
316    path.components()
317        .any(|component| matches!(component, Component::ParentDir))
318}
319
320/// Ensures the requested path stays within the workspace namespace before following symlinks.
321pub(crate) fn ensure_path_in_workspace_namespace(
322    workspace: &Path,
323    resolved_workspace: &Path,
324    requested_path: &Path,
325) -> Result<(), BoxError> {
326    if requested_path.starts_with(workspace) || requested_path.starts_with(resolved_workspace) {
327        return Ok(());
328    }
329
330    Err(format!(
331        "Access to paths outside the workspace is not allowed (workspace: {}, resolved_workspace: {}, requested_path: {})",
332        workspace.display(),
333        resolved_workspace.display(),
334        requested_path.display()
335    )
336    .into())
337}
338
339/// Returns the default encoding used for file writes.
340pub(crate) fn default_write_encoding() -> String {
341    UTF8_ENCODING.to_string()
342}
343
344/// Returns true when a file has multiple hard links.
345///
346/// Multiple links can allow path-based workspace guards to be bypassed by
347/// linking a workspace path to external sensitive content.
348pub(crate) fn has_multiple_hard_links(metadata: &Metadata) -> bool {
349    link_count(metadata) > 1
350}
351
352pub(crate) fn ensure_regular_file(
353    metadata: &Metadata,
354    path: &Path,
355    hard_link_error: &str,
356) -> Result<(), BoxError> {
357    if has_multiple_hard_links(metadata) {
358        return Err(format!("{} (path: {})", hard_link_error, path.display()).into());
359    }
360
361    if !metadata.is_file() {
362        return Err(format!(
363            "Path does not point to a regular file (path: {})",
364            path.display()
365        )
366        .into());
367    }
368
369    Ok(())
370}
371
372pub(crate) fn ensure_file_size_within_limit(
373    metadata: &Metadata,
374    path: &Path,
375    max_size_bytes: u64,
376) -> Result<(), BoxError> {
377    if metadata.len() > max_size_bytes {
378        return Err(format!(
379            "File size {} exceeds maximum allowed size of {} bytes (path: {})",
380            metadata.len(),
381            max_size_bytes,
382            path.display()
383        )
384        .into());
385    }
386
387    Ok(())
388}
389
390#[cfg(unix)]
391fn link_count(metadata: &Metadata) -> u64 {
392    use std::os::unix::fs::MetadataExt;
393    metadata.nlink()
394}
395
396#[cfg(windows)]
397fn link_count(_metadata: &Metadata) -> u64 {
398    // Rust stable does not currently expose a portable, stable Windows hard-link
399    // count API on `std::fs::Metadata`. Returning 1 avoids false positive blocks
400    // and keeps Windows builds stable until a supported API is available.
401    1
402}
403
404#[cfg(not(any(unix, windows)))]
405fn link_count(_metadata: &Metadata) -> u64 {
406    1
407}
408
409/// Atomically writes data to a file by first writing to a temporary file and then renaming it into place.
410pub async fn atomic_write_file(
411    target_path: &Path,
412    data: &[u8],
413    existing_permissions: Option<&Permissions>,
414) -> Result<(), BoxError> {
415    let temp_path =
416        write_temp_file_for_atomic_replace(target_path, data, existing_permissions).await?;
417
418    if let Err(err) = commit_atomic_replace(&temp_path, target_path).await {
419        let _ = tokio::fs::remove_file(&temp_path).await;
420        return Err(err);
421    }
422
423    Ok(())
424}
425
426pub(crate) async fn write_temp_file_for_atomic_replace(
427    target_path: &Path,
428    data: &[u8],
429    existing_permissions: Option<&Permissions>,
430) -> Result<PathBuf, BoxError> {
431    for _ in 0..16 {
432        let temp_path = atomic_temp_path(target_path)?;
433        let mut file = match tokio::fs::OpenOptions::new()
434            .create_new(true)
435            .write(true)
436            .open(&temp_path)
437            .await
438        {
439            Ok(file) => file,
440            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
441            Err(err) => {
442                return Err(format!(
443                    "Failed to create temporary file (target_path: {}, temp_path: {}): {err}",
444                    target_path.display(),
445                    temp_path.display()
446                )
447                .into());
448            }
449        };
450
451        let write_result = async {
452            file.write_all(data)
453                .await
454                .map_err(|err| {
455                    format!(
456                        "Failed to write temporary file (target_path: {}, temp_path: {}): {err}",
457                        target_path.display(),
458                        temp_path.display()
459                    )
460                })?;
461
462            if let Some(permissions) = existing_permissions {
463                tokio::fs::set_permissions(&temp_path, permissions.clone())
464                    .await
465                    .map_err(|err| {
466                        format!(
467                            "Failed to apply file permissions (target_path: {}, temp_path: {}): {err}",
468                            target_path.display(),
469                            temp_path.display()
470                        )
471                    })?;
472            }
473
474            file.sync_all()
475                .await
476                .map_err(|err| {
477                    format!(
478                        "Failed to sync temporary file (target_path: {}, temp_path: {}): {err}",
479                        target_path.display(),
480                        temp_path.display()
481                    )
482                })?;
483
484            Ok::<(), BoxError>(())
485        }
486        .await;
487        drop(file);
488
489        if let Err(err) = write_result {
490            let _ = tokio::fs::remove_file(&temp_path).await;
491            return Err(err);
492        }
493
494        return Ok(temp_path);
495    }
496
497    Err(format!(
498        "Failed to allocate unique temporary file for atomic write (target_path: {})",
499        target_path.display()
500    )
501    .into())
502}
503
504pub(crate) async fn commit_atomic_replace(
505    temp_path: &Path,
506    target_path: &Path,
507) -> Result<(), BoxError> {
508    tokio::fs::rename(temp_path, target_path)
509        .await
510        .map_err(|err| {
511            format!(
512                "Failed to atomically replace file (temp_path: {}, target_path: {}): {err}",
513                temp_path.display(),
514                target_path.display()
515            )
516            .into()
517        })
518}
519
520fn atomic_temp_path(target_path: &Path) -> Result<PathBuf, BoxError> {
521    let parent = target_path.parent().ok_or_else(|| {
522        format!(
523            "Failed to determine parent directory for write target (target_path: {})",
524            target_path.display()
525        )
526    })?;
527    let file_name = target_path.file_name().ok_or_else(|| {
528        format!(
529            "Failed to determine file name for write target (target_path: {})",
530            target_path.display()
531        )
532    })?;
533
534    let mut temp_name = OsString::from(".");
535    temp_name.push(file_name);
536    temp_name.push(format!(".anda-tmp-{:016x}", rand::random::<u64>()));
537
538    Ok(parent.join(temp_name))
539}
540
541/// Finds the nearest existing path component and returns the missing tail components.
542pub(crate) async fn nearest_existing_ancestor(
543    path: &Path,
544) -> Result<(PathBuf, Vec<OsString>), BoxError> {
545    let mut current = path.to_path_buf();
546    let mut missing_components = Vec::new();
547
548    loop {
549        match tokio::fs::symlink_metadata(&current).await {
550            Ok(_) => return Ok((current, missing_components)),
551            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
552                let file_name = current.file_name().ok_or_else(|| {
553                    format!(
554                        "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
555                        path.display(),
556                        current.display()
557                    )
558                })?;
559                missing_components.push(file_name.to_os_string());
560                current = current
561                    .parent()
562                    .ok_or_else(|| {
563                        format!(
564                            "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
565                            path.display(),
566                            current.display()
567                        )
568                    })?
569                    .to_path_buf();
570            }
571            Err(err) => {
572                return Err(format!(
573                    "Failed to inspect file path while resolving ancestor (requested_path: {}, current_path: {}): {err}",
574                    path.display(),
575                    current.display()
576                )
577                .into())
578            }
579        }
580    }
581}
582
583pub(crate) fn normalize_relative_path(path: &Path) -> String {
584    let value = path
585        .to_string_lossy()
586        .replace(std::path::MAIN_SEPARATOR, "/");
587    if value.is_empty() {
588        ".".to_string()
589    } else {
590        value
591    }
592}