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}