tool_permissions.rs

  1use crate::{
  2    Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision,
  3    decide_permission_for_path,
  4};
  5use anyhow::{Result, anyhow};
  6use fs::Fs;
  7use gpui::{App, Entity, Task, WeakEntity};
  8use project::{Project, ProjectPath};
  9use settings::Settings;
 10use std::ffi::OsStr;
 11use std::path::{Path, PathBuf};
 12use std::sync::Arc;
 13
 14pub enum SensitiveSettingsKind {
 15    Local,
 16    Global,
 17}
 18
 19/// Result of resolving a path within the project with symlink safety checks.
 20///
 21/// See [`resolve_project_path`].
 22#[derive(Debug, Clone)]
 23pub enum ResolvedProjectPath {
 24    /// The path resolves to a location safely within the project boundaries.
 25    Safe(ProjectPath),
 26    /// The path resolves through a symlink to a location outside the project.
 27    /// Agent tools should prompt the user before proceeding with access.
 28    SymlinkEscape {
 29        /// The project-relative path (before symlink resolution).
 30        project_path: ProjectPath,
 31        /// The canonical (real) filesystem path the symlink points to.
 32        canonical_target: PathBuf,
 33    },
 34}
 35
 36/// Asynchronously canonicalizes the absolute paths of all worktrees in a
 37/// project using the provided `Fs`. The returned paths can be passed to
 38/// [`resolve_project_path`] and related helpers so that they don't need to
 39/// perform blocking filesystem I/O themselves.
 40pub async fn canonicalize_worktree_roots<C: gpui::AppContext>(
 41    project: &Entity<Project>,
 42    fs: &Arc<dyn Fs>,
 43    cx: &C,
 44) -> Vec<PathBuf> {
 45    let abs_paths: Vec<Arc<Path>> = project.read_with(cx, |project, cx| {
 46        project
 47            .worktrees(cx)
 48            .map(|worktree| worktree.read(cx).abs_path())
 49            .collect()
 50    });
 51
 52    let mut canonical_roots = Vec::with_capacity(abs_paths.len());
 53    for abs_path in &abs_paths {
 54        match fs.canonicalize(abs_path).await {
 55            Ok(canonical) => canonical_roots.push(canonical),
 56            Err(_) => canonical_roots.push(abs_path.to_path_buf()),
 57        }
 58    }
 59    canonical_roots
 60}
 61
 62/// Walks up ancestors of `path` to find the deepest one that exists on disk and
 63/// can be canonicalized, then reattaches the remaining suffix components.
 64///
 65/// This is needed for paths where the leaf (or intermediate directories) don't
 66/// exist yet but an ancestor may be a symlink. For example, when creating
 67/// `.zed/settings.json` where `.zed` is a symlink to an external directory.
 68///
 69/// Note: intermediate directories *can* be symlinks (not just leaf entries),
 70/// so we must walk the full ancestor chain. For example:
 71///   `ln -s /external/config /project/.zed`
 72/// makes `.zed` an intermediate symlink directory.
 73async fn canonicalize_with_ancestors(path: &Path, fs: &dyn Fs) -> Option<PathBuf> {
 74    let mut current: Option<&Path> = Some(path);
 75    let mut suffix_components = Vec::new();
 76    loop {
 77        match current {
 78            Some(ancestor) => match fs.canonicalize(ancestor).await {
 79                Ok(canonical) => {
 80                    let mut result = canonical;
 81                    for component in suffix_components.into_iter().rev() {
 82                        result.push(component);
 83                    }
 84                    return Some(result);
 85                }
 86                Err(_) => {
 87                    if let Some(file_name) = ancestor.file_name() {
 88                        suffix_components.push(file_name.to_os_string());
 89                    }
 90                    current = ancestor.parent();
 91                }
 92            },
 93            None => return None,
 94        }
 95    }
 96}
 97
 98fn is_within_any_worktree(canonical_path: &Path, canonical_worktree_roots: &[PathBuf]) -> bool {
 99    canonical_worktree_roots
100        .iter()
101        .any(|root| canonical_path.starts_with(root))
102}
103
104/// Returns the kind of sensitive settings location this path targets, if any:
105/// either inside a `.zed/` local-settings directory or inside the global config dir.
106pub async fn sensitive_settings_kind(path: &Path, fs: &dyn Fs) -> Option<SensitiveSettingsKind> {
107    let local_settings_folder = paths::local_settings_folder_name();
108    if path.components().any(|component| {
109        component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
110    }) {
111        return Some(SensitiveSettingsKind::Local);
112    }
113
114    if let Some(canonical_path) = canonicalize_with_ancestors(path, fs).await {
115        let config_dir = fs
116            .canonicalize(paths::config_dir())
117            .await
118            .unwrap_or_else(|_| paths::config_dir().to_path_buf());
119        if canonical_path.starts_with(&config_dir) {
120            return Some(SensitiveSettingsKind::Global);
121        }
122    }
123
124    None
125}
126
127pub async fn is_sensitive_settings_path(path: &Path, fs: &dyn Fs) -> bool {
128    sensitive_settings_kind(path, fs).await.is_some()
129}
130
131/// Resolves a path within the project, checking for symlink escapes.
132///
133/// This is the primary entry point for agent tools that need to resolve a
134/// user-provided path string into a validated `ProjectPath`. It combines
135/// path lookup (`find_project_path`) with symlink safety verification.
136///
137/// `canonical_worktree_roots` should be obtained from
138/// [`canonicalize_worktree_roots`] before calling this function so that no
139/// blocking I/O is needed here.
140///
141/// # Returns
142///
143/// - `Ok(ResolvedProjectPath::Safe(project_path))` — the path resolves to a
144///   location within the project boundaries.
145/// - `Ok(ResolvedProjectPath::SymlinkEscape { .. })` — the path resolves
146///   through a symlink to a location outside the project. Agent tools should
147///   prompt the user before proceeding.
148/// - `Err(..)` — the path could not be found in the project or could not be
149///   verified. The error message is suitable for returning to the model.
150pub fn resolve_project_path(
151    project: &Project,
152    path: impl AsRef<Path>,
153    canonical_worktree_roots: &[PathBuf],
154    cx: &App,
155) -> Result<ResolvedProjectPath> {
156    let path = path.as_ref();
157    let project_path = project
158        .find_project_path(path, cx)
159        .ok_or_else(|| anyhow!("Path {} is not in the project", path.display()))?;
160
161    let worktree = project
162        .worktree_for_id(project_path.worktree_id, cx)
163        .ok_or_else(|| anyhow!("Could not resolve path {}", path.display()))?;
164    let snapshot = worktree.read(cx);
165
166    // Fast path: if the entry exists in the snapshot and is not marked
167    // external, we know it's safe (the background scanner already verified).
168    if let Some(entry) = snapshot.entry_for_path(&project_path.path) {
169        if !entry.is_external {
170            return Ok(ResolvedProjectPath::Safe(project_path));
171        }
172
173        // Entry is external (set by the worktree scanner when a symlink's
174        // canonical target is outside the worktree root). Return the
175        // canonical path if the entry has one, otherwise fall through to
176        // filesystem-level canonicalization.
177        if let Some(canonical) = &entry.canonical_path {
178            if is_within_any_worktree(canonical.as_ref(), canonical_worktree_roots) {
179                return Ok(ResolvedProjectPath::Safe(project_path));
180            }
181
182            return Ok(ResolvedProjectPath::SymlinkEscape {
183                project_path,
184                canonical_target: canonical.to_path_buf(),
185            });
186        }
187    }
188
189    // For missing/create-mode paths (or external descendants without their own
190    // canonical_path), resolve symlink safety through snapshot metadata rather
191    // than std::fs canonicalization. This keeps behavior correct for non-local
192    // worktrees and in-memory fs backends.
193    for ancestor in project_path.path.ancestors() {
194        let Some(ancestor_entry) = snapshot.entry_for_path(ancestor) else {
195            continue;
196        };
197
198        if !ancestor_entry.is_external {
199            return Ok(ResolvedProjectPath::Safe(project_path));
200        }
201
202        let Some(canonical_ancestor) = ancestor_entry.canonical_path.as_ref() else {
203            continue;
204        };
205
206        let suffix = project_path.path.strip_prefix(ancestor).map_err(|_| {
207            anyhow!(
208                "Path {} could not be resolved in the project",
209                path.display()
210            )
211        })?;
212
213        let canonical_target = if suffix.is_empty() {
214            canonical_ancestor.to_path_buf()
215        } else {
216            canonical_ancestor.join(suffix.as_std_path())
217        };
218
219        if is_within_any_worktree(&canonical_target, canonical_worktree_roots) {
220            return Ok(ResolvedProjectPath::Safe(project_path));
221        }
222
223        return Ok(ResolvedProjectPath::SymlinkEscape {
224            project_path,
225            canonical_target,
226        });
227    }
228
229    Ok(ResolvedProjectPath::Safe(project_path))
230}
231
232/// Prompts the user for permission when a path resolves through a symlink to a
233/// location outside the project. This check is an additional gate after
234/// settings-based deny decisions: even if a tool is configured as "always allow,"
235/// a symlink escape still requires explicit user approval.
236pub fn authorize_symlink_access(
237    tool_name: &str,
238    display_path: &str,
239    canonical_target: &Path,
240    event_stream: &ToolCallEventStream,
241    cx: &mut App,
242) -> Task<Result<()>> {
243    let title = format!(
244        "`{}` points outside the project (symlink to `{}`)",
245        display_path,
246        canonical_target.display(),
247    );
248
249    let context = ToolPermissionContext::symlink_target(
250        tool_name,
251        vec![canonical_target.display().to_string()],
252    );
253
254    event_stream.authorize(title, context, cx)
255}
256
257/// Creates a single authorization prompt for multiple symlink escapes.
258/// Each escape is a `(display_path, canonical_target)` pair.
259///
260/// Accepts `&[(&str, PathBuf)]` to match the natural return type of
261/// [`detect_symlink_escape`], avoiding intermediate owned-to-borrowed
262/// conversions at call sites.
263pub fn authorize_symlink_escapes(
264    tool_name: &str,
265    escapes: &[(&str, PathBuf)],
266    event_stream: &ToolCallEventStream,
267    cx: &mut App,
268) -> Task<Result<()>> {
269    debug_assert!(!escapes.is_empty());
270
271    if escapes.len() == 1 {
272        return authorize_symlink_access(tool_name, escapes[0].0, &escapes[0].1, event_stream, cx);
273    }
274
275    let targets = escapes
276        .iter()
277        .map(|(path, target)| format!("`{}` → `{}`", path, target.display()))
278        .collect::<Vec<_>>()
279        .join(" and ");
280    let title = format!("{} (symlinks outside project)", targets);
281
282    let context = ToolPermissionContext::symlink_target(
283        tool_name,
284        escapes
285            .iter()
286            .map(|(_, target)| target.display().to_string())
287            .collect(),
288    );
289
290    event_stream.authorize(title, context, cx)
291}
292
293/// Checks whether a path escapes the project via symlink, without creating
294/// an authorization task. Useful for pre-filtering paths before settings checks.
295pub fn path_has_symlink_escape(
296    project: &Project,
297    path: impl AsRef<Path>,
298    canonical_worktree_roots: &[PathBuf],
299    cx: &App,
300) -> bool {
301    matches!(
302        resolve_project_path(project, path, canonical_worktree_roots, cx),
303        Ok(ResolvedProjectPath::SymlinkEscape { .. })
304    )
305}
306
307/// Collects symlink escape info for a path without creating an authorization task.
308/// Returns `Some((display_path, canonical_target))` if the path escapes via symlink.
309pub fn detect_symlink_escape<'a>(
310    project: &Project,
311    display_path: &'a str,
312    canonical_worktree_roots: &[PathBuf],
313    cx: &App,
314) -> Option<(&'a str, PathBuf)> {
315    match resolve_project_path(project, display_path, canonical_worktree_roots, cx).ok()? {
316        ResolvedProjectPath::Safe(_) => None,
317        ResolvedProjectPath::SymlinkEscape {
318            canonical_target, ..
319        } => Some((display_path, canonical_target)),
320    }
321}
322
323/// Collects symlink escape info for two paths (source and destination) and
324/// returns any escapes found. This deduplicates the common pattern used by
325/// tools that operate on two paths (copy, move).
326///
327/// Returns a `Vec` of `(display_path, canonical_target)` pairs for paths
328/// that escape the project via symlink. The returned vec borrows the display
329/// paths from the input strings.
330pub fn collect_symlink_escapes<'a>(
331    project: &Project,
332    source_path: &'a str,
333    destination_path: &'a str,
334    canonical_worktree_roots: &[PathBuf],
335    cx: &App,
336) -> Vec<(&'a str, PathBuf)> {
337    let mut escapes = Vec::new();
338    if let Some(escape) = detect_symlink_escape(project, source_path, canonical_worktree_roots, cx)
339    {
340        escapes.push(escape);
341    }
342    if let Some(escape) =
343        detect_symlink_escape(project, destination_path, canonical_worktree_roots, cx)
344    {
345        escapes.push(escape);
346    }
347    escapes
348}
349
350/// Checks authorization for file edits, handling symlink escapes and
351/// sensitive settings paths.
352///
353/// # Authorization precedence
354///
355/// When a symlink escape is detected, the symlink authorization prompt
356/// *replaces* (rather than supplements) the normal tool-permission prompt.
357/// This is intentional: the symlink prompt already requires explicit user
358/// approval and displays the canonical target, which provides strictly more
359/// security-relevant information than the generic tool confirmation. Requiring
360/// two sequential prompts for the same operation would degrade UX without
361/// meaningfully improving security, since the user must already approve the
362/// more specific symlink-escape prompt.
363pub fn authorize_file_edit(
364    tool_name: &str,
365    path: &Path,
366    display_description: &str,
367    thread: &WeakEntity<Thread>,
368    event_stream: &ToolCallEventStream,
369    cx: &mut App,
370) -> Task<Result<()>> {
371    let path_str = path.to_string_lossy();
372
373    let settings = agent_settings::AgentSettings::get_global(cx);
374    let decision = decide_permission_for_path(tool_name, &path_str, settings);
375
376    if let ToolPermissionDecision::Deny(reason) = decision {
377        return Task::ready(Err(anyhow!("{}", reason)));
378    }
379
380    let path_owned = path.to_path_buf();
381    let display_description = display_description.to_string();
382    let tool_name = tool_name.to_string();
383    let thread = thread.clone();
384    let event_stream = event_stream.clone();
385
386    // The local settings folder check is synchronous (pure path inspection),
387    // so we can handle this common case without spawning.
388    let local_settings_folder = paths::local_settings_folder_name();
389    let is_local_settings = path.components().any(|component| {
390        component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
391    });
392
393    cx.spawn(async move |cx| {
394        // Resolve the path and check for symlink escapes.
395        let (project_entity, fs) = thread.read_with(cx, |thread, cx| {
396            let project = thread.project().clone();
397            let fs = project.read(cx).fs().clone();
398            (project, fs)
399        })?;
400
401        let canonical_roots = canonicalize_worktree_roots(&project_entity, &fs, cx).await;
402
403        let resolved = project_entity.read_with(cx, |project, cx| {
404            resolve_project_path(project, &path_owned, &canonical_roots, cx)
405        });
406
407        if let Ok(ResolvedProjectPath::SymlinkEscape {
408            canonical_target, ..
409        }) = &resolved
410        {
411            let authorize = cx.update(|cx| {
412                authorize_symlink_access(
413                    &tool_name,
414                    &path_owned.to_string_lossy(),
415                    canonical_target,
416                    &event_stream,
417                    cx,
418                )
419            });
420            return authorize.await;
421        }
422
423        // Create-mode paths may not resolve yet, so also inspect the parent path
424        // for symlink escapes before applying settings-based allow decisions.
425        if resolved.is_err() {
426            if let Some(parent_path) = path_owned.parent() {
427                let parent_resolved = project_entity.read_with(cx, |project, cx| {
428                    resolve_project_path(project, parent_path, &canonical_roots, cx)
429                });
430
431                if let Ok(ResolvedProjectPath::SymlinkEscape {
432                    canonical_target, ..
433                }) = &parent_resolved
434                {
435                    let authorize = cx.update(|cx| {
436                        authorize_symlink_access(
437                            &tool_name,
438                            &path_owned.to_string_lossy(),
439                            canonical_target,
440                            &event_stream,
441                            cx,
442                        )
443                    });
444                    return authorize.await;
445                }
446            }
447        }
448
449        let explicitly_allowed = matches!(decision, ToolPermissionDecision::Allow);
450
451        // Check sensitive settings asynchronously.
452        let settings_kind = if is_local_settings {
453            Some(SensitiveSettingsKind::Local)
454        } else {
455            sensitive_settings_kind(&path_owned, fs.as_ref()).await
456        };
457
458        let is_sensitive = settings_kind.is_some();
459        if explicitly_allowed && !is_sensitive {
460            return Ok(());
461        }
462
463        match settings_kind {
464            Some(SensitiveSettingsKind::Local) => {
465                let authorize = cx.update(|cx| {
466                    let context = ToolPermissionContext::new(
467                        &tool_name,
468                        vec![path_owned.to_string_lossy().to_string()],
469                    );
470                    event_stream.authorize(
471                        format!("{} (local settings)", display_description),
472                        context,
473                        cx,
474                    )
475                });
476                return authorize.await;
477            }
478            Some(SensitiveSettingsKind::Global) => {
479                let authorize = cx.update(|cx| {
480                    let context = ToolPermissionContext::new(
481                        &tool_name,
482                        vec![path_owned.to_string_lossy().to_string()],
483                    );
484                    event_stream.authorize(
485                        format!("{} (settings)", display_description),
486                        context,
487                        cx,
488                    )
489                });
490                return authorize.await;
491            }
492            None => {}
493        }
494
495        match resolved {
496            Ok(_) => Ok(()),
497            Err(_) => {
498                let authorize = cx.update(|cx| {
499                    let context = ToolPermissionContext::new(
500                        &tool_name,
501                        vec![path_owned.to_string_lossy().to_string()],
502                    );
503                    event_stream.authorize(&display_description, context, cx)
504                });
505                authorize.await
506            }
507        }
508    })
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use fs::Fs;
515    use gpui::TestAppContext;
516    use project::{FakeFs, Project};
517    use serde_json::json;
518    use settings::SettingsStore;
519    use util::path;
520
521    fn init_test(cx: &mut TestAppContext) {
522        cx.update(|cx| {
523            let settings_store = SettingsStore::test(cx);
524            cx.set_global(settings_store);
525        });
526    }
527
528    async fn worktree_roots(
529        project: &Entity<Project>,
530        fs: &Arc<dyn Fs>,
531        cx: &TestAppContext,
532    ) -> Vec<PathBuf> {
533        let abs_paths: Vec<Arc<Path>> = project.read_with(cx, |project, cx| {
534            project
535                .worktrees(cx)
536                .map(|wt| wt.read(cx).abs_path())
537                .collect()
538        });
539
540        let mut roots = Vec::with_capacity(abs_paths.len());
541        for p in &abs_paths {
542            match fs.canonicalize(p).await {
543                Ok(c) => roots.push(c),
544                Err(_) => roots.push(p.to_path_buf()),
545            }
546        }
547        roots
548    }
549
550    #[gpui::test]
551    async fn test_resolve_project_path_safe_for_normal_files(cx: &mut TestAppContext) {
552        init_test(cx);
553
554        let fs = FakeFs::new(cx.executor());
555        fs.insert_tree(
556            path!("/root/project"),
557            json!({
558                "src": {
559                    "main.rs": "fn main() {}",
560                    "lib.rs": "pub fn hello() {}"
561                },
562                "README.md": "# Project"
563            }),
564        )
565        .await;
566
567        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
568        cx.run_until_parked();
569        let fs_arc: Arc<dyn Fs> = fs;
570        let roots = worktree_roots(&project, &fs_arc, cx).await;
571
572        cx.read(|cx| {
573            let project = project.read(cx);
574
575            let resolved = resolve_project_path(project, "project/src/main.rs", &roots, cx)
576                .expect("should resolve normal file");
577            assert!(
578                matches!(resolved, ResolvedProjectPath::Safe(_)),
579                "normal file should be Safe, got: {:?}",
580                resolved
581            );
582
583            let resolved = resolve_project_path(project, "project/README.md", &roots, cx)
584                .expect("should resolve readme");
585            assert!(
586                matches!(resolved, ResolvedProjectPath::Safe(_)),
587                "readme should be Safe, got: {:?}",
588                resolved
589            );
590        });
591    }
592
593    #[gpui::test]
594    async fn test_resolve_project_path_detects_symlink_escape(cx: &mut TestAppContext) {
595        init_test(cx);
596
597        let fs = FakeFs::new(cx.executor());
598        fs.insert_tree(
599            path!("/root"),
600            json!({
601                "project": {
602                    "src": {
603                        "main.rs": "fn main() {}"
604                    }
605                },
606                "external": {
607                    "secret.txt": "top secret"
608                }
609            }),
610        )
611        .await;
612
613        fs.create_symlink(path!("/root/project/link").as_ref(), "../external".into())
614            .await
615            .expect("should create symlink");
616
617        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
618        cx.run_until_parked();
619        let fs_arc: Arc<dyn Fs> = fs;
620        let roots = worktree_roots(&project, &fs_arc, cx).await;
621
622        cx.read(|cx| {
623            let project = project.read(cx);
624
625            let resolved = resolve_project_path(project, "project/link", &roots, cx)
626                .expect("should resolve symlink path");
627            match &resolved {
628                ResolvedProjectPath::SymlinkEscape {
629                    canonical_target, ..
630                } => {
631                    assert_eq!(
632                        canonical_target,
633                        Path::new(path!("/root/external")),
634                        "canonical target should point to external directory"
635                    );
636                }
637                ResolvedProjectPath::Safe(_) => {
638                    panic!("symlink escaping project should be detected as SymlinkEscape");
639                }
640            }
641        });
642    }
643
644    #[gpui::test]
645    async fn test_resolve_project_path_allows_intra_project_symlinks(cx: &mut TestAppContext) {
646        init_test(cx);
647
648        let fs = FakeFs::new(cx.executor());
649        fs.insert_tree(
650            path!("/root/project"),
651            json!({
652                "real_dir": {
653                    "file.txt": "hello"
654                }
655            }),
656        )
657        .await;
658
659        fs.create_symlink(path!("/root/project/link_dir").as_ref(), "real_dir".into())
660            .await
661            .expect("should create symlink");
662
663        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
664        cx.run_until_parked();
665        let fs_arc: Arc<dyn Fs> = fs;
666        let roots = worktree_roots(&project, &fs_arc, cx).await;
667
668        cx.read(|cx| {
669            let project = project.read(cx);
670
671            let resolved = resolve_project_path(project, "project/link_dir", &roots, cx)
672                .expect("should resolve intra-project symlink");
673            assert!(
674                matches!(resolved, ResolvedProjectPath::Safe(_)),
675                "intra-project symlink should be Safe, got: {:?}",
676                resolved
677            );
678        });
679    }
680
681    #[gpui::test]
682    async fn test_resolve_project_path_missing_child_under_external_symlink(
683        cx: &mut TestAppContext,
684    ) {
685        init_test(cx);
686
687        let fs = FakeFs::new(cx.executor());
688        fs.insert_tree(
689            path!("/root"),
690            json!({
691                "project": {},
692                "external": {
693                    "existing.txt": "hello"
694                }
695            }),
696        )
697        .await;
698
699        fs.create_symlink(path!("/root/project/link").as_ref(), "../external".into())
700            .await
701            .expect("should create symlink");
702
703        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
704        cx.run_until_parked();
705        let fs_arc: Arc<dyn Fs> = fs;
706        let roots = worktree_roots(&project, &fs_arc, cx).await;
707
708        cx.read(|cx| {
709            let project = project.read(cx);
710
711            let resolved = resolve_project_path(project, "project/link/new_dir", &roots, cx)
712                .expect("should resolve missing child path under symlink");
713            match resolved {
714                ResolvedProjectPath::SymlinkEscape {
715                    canonical_target, ..
716                } => {
717                    assert_eq!(
718                        canonical_target,
719                        Path::new(path!("/root/external/new_dir")),
720                        "missing child path should resolve to escaped canonical target",
721                    );
722                }
723                ResolvedProjectPath::Safe(_) => {
724                    panic!("missing child under external symlink should be SymlinkEscape");
725                }
726            }
727        });
728    }
729
730    #[gpui::test]
731    async fn test_resolve_project_path_allows_cross_worktree_symlinks(cx: &mut TestAppContext) {
732        init_test(cx);
733
734        let fs = FakeFs::new(cx.executor());
735        fs.insert_tree(
736            path!("/root"),
737            json!({
738                "worktree_one": {},
739                "worktree_two": {
740                    "shared_dir": {
741                        "file.txt": "hello"
742                    }
743                }
744            }),
745        )
746        .await;
747
748        fs.create_symlink(
749            path!("/root/worktree_one/link_to_worktree_two").as_ref(),
750            PathBuf::from("../worktree_two/shared_dir"),
751        )
752        .await
753        .expect("should create symlink");
754
755        let project = Project::test(
756            fs.clone(),
757            [
758                path!("/root/worktree_one").as_ref(),
759                path!("/root/worktree_two").as_ref(),
760            ],
761            cx,
762        )
763        .await;
764        cx.run_until_parked();
765        let fs_arc: Arc<dyn Fs> = fs;
766        let roots = worktree_roots(&project, &fs_arc, cx).await;
767
768        cx.read(|cx| {
769            let project = project.read(cx);
770
771            let resolved =
772                resolve_project_path(project, "worktree_one/link_to_worktree_two", &roots, cx)
773                    .expect("should resolve cross-worktree symlink");
774            assert!(
775                matches!(resolved, ResolvedProjectPath::Safe(_)),
776                "cross-worktree symlink should be Safe, got: {:?}",
777                resolved
778            );
779        });
780    }
781
782    #[gpui::test]
783    async fn test_resolve_project_path_missing_child_under_cross_worktree_symlink(
784        cx: &mut TestAppContext,
785    ) {
786        init_test(cx);
787
788        let fs = FakeFs::new(cx.executor());
789        fs.insert_tree(
790            path!("/root"),
791            json!({
792                "worktree_one": {},
793                "worktree_two": {
794                    "shared_dir": {}
795                }
796            }),
797        )
798        .await;
799
800        fs.create_symlink(
801            path!("/root/worktree_one/link_to_worktree_two").as_ref(),
802            PathBuf::from("../worktree_two/shared_dir"),
803        )
804        .await
805        .expect("should create symlink");
806
807        let project = Project::test(
808            fs.clone(),
809            [
810                path!("/root/worktree_one").as_ref(),
811                path!("/root/worktree_two").as_ref(),
812            ],
813            cx,
814        )
815        .await;
816        cx.run_until_parked();
817        let fs_arc: Arc<dyn Fs> = fs;
818        let roots = worktree_roots(&project, &fs_arc, cx).await;
819
820        cx.read(|cx| {
821            let project = project.read(cx);
822
823            let resolved = resolve_project_path(
824                project,
825                "worktree_one/link_to_worktree_two/new_dir",
826                &roots,
827                cx,
828            )
829            .expect("should resolve missing child under cross-worktree symlink");
830            assert!(
831                matches!(resolved, ResolvedProjectPath::Safe(_)),
832                "missing child under cross-worktree symlink should be Safe, got: {:?}",
833                resolved
834            );
835        });
836    }
837}