1use std::ffi::OsString;
9use std::io;
10use std::path::{Component, Path, PathBuf, Prefix, PrefixComponent};
11use std::sync::Arc;
12
13use lsp_types::Uri;
14use thiserror::Error as ThisError;
15use tracing::{debug, info, warn};
16
17use super::{ClientPath, uri_to_path};
18use crate::config::ConfiguredRoot;
19use crate::error::{BackgroundTask, ConfigError, Error};
20
21mod open_contained;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum CaseRule {
26 Exact,
27 FoldAscii,
28}
29
30impl CaseRule {
31 const LEXICAL: Self = if cfg!(any(windows, target_os = "macos")) {
34 Self::FoldAscii
35 } else {
36 Self::Exact
37 };
38
39 fn components_eq(self, a: Component<'_>, b: Component<'_>) -> bool {
40 match (a, b) {
41 (Component::Prefix(a), Component::Prefix(b)) => prefix_eq(a, b),
42 _ => match self {
43 Self::Exact => a == b,
44 Self::FoldAscii => a
45 .as_os_str()
46 .to_string_lossy()
47 .eq_ignore_ascii_case(&b.as_os_str().to_string_lossy()),
48 },
49 }
50 }
51}
52
53fn prefix_eq(a: PrefixComponent<'_>, b: PrefixComponent<'_>) -> bool {
57 let same_name = |x: &std::ffi::OsStr, y: &std::ffi::OsStr| {
58 x.to_string_lossy()
59 .eq_ignore_ascii_case(&y.to_string_lossy())
60 };
61 match (a.kind(), b.kind()) {
62 (Prefix::Disk(x) | Prefix::VerbatimDisk(x), Prefix::Disk(y) | Prefix::VerbatimDisk(y)) => {
63 x.eq_ignore_ascii_case(&y)
64 }
65 (
66 Prefix::UNC(server_a, share_a) | Prefix::VerbatimUNC(server_a, share_a),
67 Prefix::UNC(server_b, share_b) | Prefix::VerbatimUNC(server_b, share_b),
68 ) => same_name(server_a, server_b) && same_name(share_a, share_b),
69 _ => a == b,
70 }
71}
72
73fn strip_root<'p>(path: &'p Path, root: &Path, rule: CaseRule) -> Option<&'p Path> {
78 let mut components = path.components();
79 root.components()
80 .all(|r| components.next().is_some_and(|c| rule.components_eq(c, r)))
81 .then_some(components.as_path())
82}
83
84fn is_within(path: &Path, root: &Path, rule: CaseRule) -> bool {
85 strip_root(path, root, rule).is_some()
86}
87
88pub fn lexically_normalize(path: &Path) -> PathBuf {
91 let mut parts: Vec<Component<'_>> = Vec::new();
92 for component in path.components() {
93 match component {
94 Component::CurDir => {}
95 Component::ParentDir => match parts.last() {
96 Some(Component::Normal(_)) => {
97 parts.pop();
98 }
99 Some(Component::RootDir | Component::Prefix(_)) => {}
100 _ => parts.push(component),
101 },
102 other => parts.push(other),
103 }
104 }
105 parts.iter().collect()
106}
107
108pub type CanonicalizeFn = dyn Fn(&Path) -> io::Result<PathBuf> + Send + Sync;
111
112#[derive(Debug, ThisError)]
114pub enum Unresolved {
115 #[error("transient filesystem error")]
117 Transient(#[source] io::Error),
118 #[error("no ancestor of the path exists")]
120 NoExistingAncestor,
121 #[error("path contains a `..` component")]
124 ParentComponent,
125}
126
127fn canonicalize_on_disk(path: &Path) -> io::Result<PathBuf> {
131 dunce::canonicalize(path).map_err(|error| {
132 if error.kind() == io::ErrorKind::NotFound && std::fs::symlink_metadata(path).is_ok() {
133 io::Error::other("dangling symlink")
134 } else {
135 error
136 }
137 })
138}
139
140pub fn canonicalize_existing_prefix(
152 path: &Path,
153 canonicalize: &CanonicalizeFn,
154) -> Result<PathBuf, Unresolved> {
155 if path.components().any(|c| matches!(c, Component::ParentDir)) {
156 return Err(Unresolved::ParentComponent);
157 }
158 for ancestor in path.ancestors() {
159 match canonicalize(ancestor) {
160 Ok(canonical) => {
161 return path
162 .strip_prefix(ancestor)
163 .map(|rest| lexically_normalize(&canonical.join(rest)))
164 .map_err(|_| Unresolved::NoExistingAncestor);
165 }
166 Err(e)
167 if matches!(
168 e.kind(),
169 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
170 ) => {}
171 Err(e) => return Err(Unresolved::Transient(e)),
172 }
173 }
174 Err(Unresolved::NoExistingAncestor)
175}
176
177fn path_io_error(path: &Path, source: io::Error) -> Error {
180 match source.kind() {
181 io::ErrorKind::NotADirectory
182 | io::ErrorKind::InvalidFilename
183 | io::ErrorKind::InvalidInput => Error::MalformedPath {
184 path: path.to_path_buf(),
185 source,
186 },
187 _ => Error::FileIo {
188 path: path.to_path_buf(),
189 source,
190 },
191 }
192}
193
194#[derive(Debug, Clone)]
200pub struct ProcessCwd {
201 physical: PathBuf,
202 logical: Option<PathBuf>,
203}
204
205impl ProcessCwd {
206 pub(crate) fn current() -> Result<Self, Error> {
208 let physical = std::env::current_dir().map_err(Error::Io)?;
209 Ok(Self::new(physical, std::env::var_os("PWD")))
210 }
211
212 pub(crate) fn new(physical: PathBuf, pwd: Option<OsString>) -> Self {
216 let logical = pwd.map(PathBuf::from).filter(|pwd| {
217 pwd.is_absolute()
218 && matches!(
219 (dunce::canonicalize(pwd), dunce::canonicalize(&physical)),
220 (Ok(a), Ok(b)) if a == b
221 )
222 });
223 Self { physical, logical }
224 }
225}
226
227pub fn probe_root(written: &Path, base_dir: &Path, probe: &Path) -> Result<PathBuf, Error> {
235 dunce::canonicalize(probe).map_err(|source| {
236 ConfigError::UnresolvableWorkspaceRoot {
237 written: written.to_path_buf(),
238 base_dir: base_dir.to_path_buf(),
239 probe: probe.to_path_buf(),
240 source,
241 }
242 .into()
243 })
244}
245
246pub fn join_relative_root(base_dir: &Path, root: &Path) -> PathBuf {
263 let mut joined = base_dir.to_path_buf();
264 joined.extend(
265 root.components()
266 .skip_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)),
267 );
268 joined
269}
270
271fn plain_absolute_path(uri: &Uri) -> Option<PathBuf> {
274 let path = uri_to_path(uri)?;
275 let plain = path.is_absolute()
276 && !path
277 .components()
278 .any(|c| matches!(c, Component::CurDir | Component::ParentDir));
279 plain.then_some(path)
280}
281
282fn stored_alias_form(alias: &Path) -> Option<PathBuf> {
285 if !alias.is_absolute() {
286 return None;
287 }
288 let form = lexically_normalize(dunce::simplified(alias));
289 (!form.is_empty()).then_some(form)
290}
291
292struct ResolvedRoot {
295 canonical: PathBuf,
296 aliases: Vec<PathBuf>,
297}
298
299impl ResolvedRoot {
300 fn from_absolute(root: &Path) -> Self {
301 let canonical = dunce::canonicalize(root).unwrap_or_else(|source| {
302 warn!(
303 "Failed to canonicalize absolute workspace root {}: {source}, using non-canonical path",
304 root.display()
305 );
306 canonicalize_existing_prefix(root, &canonicalize_on_disk)
307 .unwrap_or_else(|_| root.to_path_buf())
308 });
309 Self {
310 canonical,
311 aliases: vec![root.to_path_buf()],
312 }
313 }
314
315 fn from_relative(root: &Path, cwd: &ProcessCwd) -> Result<Self, Error> {
316 let joined = join_relative_root(&cwd.physical, root);
317 let canonical = probe_root(root, &cwd.physical, &joined)?;
318 let mut aliases = vec![joined];
319 aliases.extend(
320 cwd.logical
321 .as_deref()
322 .map(|logical| join_relative_root(logical, root)),
323 );
324 Ok(Self { canonical, aliases })
325 }
326
327 fn from_cwd(cwd: &ProcessCwd) -> Self {
328 let canonical = dunce::canonicalize(&cwd.physical).unwrap_or_else(|e| {
329 warn!(
330 "Failed to canonicalize workspace base directory {}: {e}, using non-canonical absolute path",
331 cwd.physical.display()
332 );
333 cwd.physical.clone()
334 });
335 info!(
336 "Using workspace base directory as root: {}",
337 canonical.display()
338 );
339 let mut aliases = vec![cwd.physical.clone()];
340 aliases.extend(cwd.logical.clone());
341 Self { canonical, aliases }
342 }
343
344 fn verified_aliases(&self) -> impl Iterator<Item = PathBuf> + '_ {
348 self.aliases.iter().filter_map(|alias| {
349 let form = stored_alias_form(alias)?;
350 if form == self.canonical
351 || canonicalize_existing_prefix(&form, &canonicalize_on_disk)
352 .is_ok_and(|real| real == self.canonical)
353 {
354 Some(form)
355 } else {
356 debug!(
357 "dropping workspace root alias {} that does not resolve to {}",
358 form.display(),
359 self.canonical.display()
360 );
361 None
362 }
363 })
364 }
365}
366
367#[cfg(unix)]
370#[derive(Debug, Clone, PartialEq, Eq)]
371struct RootLink {
372 link: PathBuf,
373 target: PathBuf,
374}
375
376#[cfg(unix)]
380fn read_root_links(links_dir: &Path) -> Vec<RootLink> {
381 let entries = match std::fs::read_dir(links_dir) {
382 Ok(entries) => entries,
383 Err(err) => {
384 tracing::debug!(dir = %links_dir.display(), %err, "cannot list root-level links");
385 return Vec::new();
386 }
387 };
388 entries
389 .filter_map(Result::ok)
390 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_symlink()))
391 .filter_map(|entry| {
392 let link = entry.path();
393 let target = std::fs::read_link(&link).ok()?;
394 Some(RootLink {
395 target: lexically_normalize(&links_dir.join(target)),
396 link,
397 })
398 })
399 .collect()
400}
401
402#[cfg(unix)]
406fn system_symlink_aliases(roots: &[PathBuf], links: &[RootLink]) -> Vec<(PathBuf, PathBuf)> {
407 let mut candidates = Vec::new();
408 for root in roots {
409 for RootLink { link, target } in links {
410 if let Ok(rest) = root.strip_prefix(target) {
411 candidates.push((link.join(rest), root.clone()));
412 }
413 }
414 }
415 candidates
416}
417
418#[cfg(unix)]
421fn verified_system_aliases(links_dir: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
422 system_symlink_aliases(roots, &read_root_links(links_dir))
423 .into_iter()
424 .filter(|(alias, root)| dunce::canonicalize(alias).is_ok_and(|real| &real == root))
425 .map(|(alias, _)| alias)
426 .collect()
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Hash)]
450pub struct WorkspacePath(PathBuf);
451
452impl WorkspacePath {
453 #[must_use]
455 pub fn as_path(&self) -> &Path {
456 &self.0
457 }
458
459 #[must_use]
461 pub fn into_path_buf(self) -> PathBuf {
462 self.0
463 }
464
465 #[cfg(test)]
467 pub(crate) const fn for_test(path: PathBuf) -> Self {
468 Self(path)
469 }
470}
471
472impl AsRef<Path> for WorkspacePath {
473 fn as_ref(&self) -> &Path {
474 &self.0
475 }
476}
477
478#[derive(Debug, Clone, Default)]
489pub struct WorkspaceRoots {
490 canonical: Arc<[PathBuf]>,
491 aliases: Arc<[PathBuf]>,
492}
493
494impl WorkspaceRoots {
495 fn from_parts(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
501 let mut kept: Vec<PathBuf> = Vec::new();
502 for alias in aliases {
503 let Some(alias) = stored_alias_form(&alias) else {
504 continue;
505 };
506 if canonical.contains(&alias) || kept.contains(&alias) {
507 continue;
508 }
509 kept.push(alias);
510 }
511 Self {
512 canonical: canonical.into(),
513 aliases: kept.into(),
514 }
515 }
516
517 pub(crate) fn untrusted_boundary(
521 &self,
522 configured: &[ConfiguredRoot],
523 home: Option<&Path>,
524 ) -> Self {
525 if configured.is_empty() {
526 self.checkout_scoped(home)
527 } else {
528 self.clone()
529 }
530 }
531
532 pub(crate) fn checkout_scoped(&self, home: Option<&Path>) -> Self {
540 let scoped = |path: &Path| path.parent().is_some() && home != Some(path);
541 Self::from_parts(
542 self.canonical
543 .iter()
544 .filter(|r| scoped(r))
545 .cloned()
546 .collect(),
547 self.aliases.iter().filter(|a| scoped(a)).cloned().collect(),
548 )
549 }
550
551 #[cfg(test)]
554 pub(crate) fn for_test(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
555 Self::from_parts(canonical, aliases)
556 }
557
558 pub fn from_configured(roots: &[ConfiguredRoot]) -> Result<Self, Error> {
590 Self::from_configured_with(roots, ProcessCwd::current)
591 }
592
593 #[cfg(test)]
595 pub(crate) fn from_paths<P: AsRef<Path>>(roots: &[P]) -> Result<Self, Error> {
596 Self::from_paths_with(roots, ProcessCwd::current)
597 }
598
599 #[cfg(test)]
601 pub(crate) fn from_paths_with<P: AsRef<Path>>(
602 roots: &[P],
603 cwd: impl FnOnce() -> Result<ProcessCwd, Error>,
604 ) -> Result<Self, Error> {
605 let roots: Vec<ConfiguredRoot> = roots
606 .iter()
607 .map(|root| ConfiguredRoot::new(root.as_ref()).expect("test root"))
608 .collect();
609 Self::from_configured_with(&roots, cwd)
610 }
611
612 pub(crate) fn from_configured_with(
613 roots: &[ConfiguredRoot],
614 cwd: impl FnOnce() -> Result<ProcessCwd, Error>,
615 ) -> Result<Self, Error> {
616 let needs_cwd = roots.is_empty() || roots.iter().any(|root| root.as_path().is_relative());
617 let cwd = needs_cwd.then(cwd).transpose()?;
618
619 let resolved = match &cwd {
620 Some(cwd) if roots.is_empty() => vec![ResolvedRoot::from_cwd(cwd)],
621 _ => roots
622 .iter()
623 .map(|root| match &cwd {
624 Some(cwd) if root.as_path().is_relative() => {
625 ResolvedRoot::from_relative(root.as_path(), cwd)
626 }
627 _ => Ok(ResolvedRoot::from_absolute(root.as_path())),
628 })
629 .collect::<Result<Vec<_>, Error>>()?,
630 };
631
632 let aliases = resolved
633 .iter()
634 .flat_map(ResolvedRoot::verified_aliases)
635 .collect();
636 let canonical = resolved.into_iter().map(|root| root.canonical).collect();
637 Ok(Self::from_parts(canonical, aliases).with_system_aliases())
638 }
639
640 #[cfg(unix)]
645 #[must_use]
646 fn with_system_aliases(self) -> Self {
647 self.with_system_aliases_in(Path::new("/"))
648 }
649
650 #[cfg(not(unix))]
652 #[must_use]
653 const fn with_system_aliases(self) -> Self {
654 self
655 }
656
657 #[cfg(unix)]
658 fn with_system_aliases_in(self, links_dir: &Path) -> Self {
659 let extra = verified_system_aliases(links_dir, &self.canonical);
660 let aliases = self.aliases.iter().cloned().chain(extra).collect();
661 Self::from_parts(self.canonical.to_vec(), aliases)
662 }
663
664 pub async fn validate(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
674 self.lexical_gate(path)?;
675 let roots = self.clone();
676 let path = path.clone();
677 tokio::task::spawn_blocking(move || roots.canonical_gate(&path))
678 .await
679 .map_err(|source| Error::TaskFailed {
680 task: BackgroundTask::PathValidation,
681 source,
682 })?
683 }
684
685 pub fn validate_blocking(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
707 self.lexical_gate(path)?;
708 self.canonical_gate(path)
709 }
710
711 fn lexical_gate(&self, path: &ClientPath) -> Result<(), Error> {
712 let path = path.as_path();
713 if self.is_empty() {
714 return Err(Error::NoWorkspaceRoots(path.to_path_buf()));
715 }
716 let absolute = std::path::absolute(path).map_err(|source| path_io_error(path, source))?;
717 let normalized = lexically_normalize(dunce::simplified(&absolute));
718 self.admits_lexically(&normalized)
719 .ok_or_else(|| Error::PathOutsideWorkspace(path.to_path_buf()))
720 }
721
722 fn canonical_gate(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
723 let path = path.as_path();
724 let canonical = dunce::canonicalize(path).map_err(|source| path_io_error(path, source))?;
725 if self.contains_canonical(&canonical) {
726 Ok(WorkspacePath(canonical))
727 } else {
728 Err(Error::PathOutsideWorkspace(path.to_path_buf()))
729 }
730 }
731
732 #[must_use]
734 pub fn canonical(&self) -> &[PathBuf] {
735 &self.canonical
736 }
737
738 #[must_use]
740 pub fn is_empty(&self) -> bool {
741 self.canonical.is_empty()
742 }
743
744 pub(crate) fn admits_uri(&self, uri: &Uri) -> bool {
759 plain_absolute_path(uri).is_some_and(|path| self.admits_lexically(&path))
760 }
761
762 pub(crate) async fn admits_edit_uri(&self, uri: &Uri) -> bool {
771 let Some(path) = plain_absolute_path(uri) else {
772 return false;
773 };
774 if !self.admits_with(&path, CaseRule::Exact) {
775 return false;
776 }
777 match tokio::task::spawn_blocking(move || {
778 canonicalize_existing_prefix(&path, &canonicalize_on_disk)
779 })
780 .await
781 {
782 Ok(canonical) => canonical.is_ok_and(|canonical| self.contains_canonical(&canonical)),
783 Err(error) => {
784 tracing::warn!(%error, uri = uri.as_ref(), "edit URI canonicalization task failed; dropping the edit");
785 false
786 }
787 }
788 }
789
790 pub(crate) fn admits_lexically(&self, normalized: &Path) -> bool {
794 self.admits_with(normalized, CaseRule::LEXICAL)
795 }
796
797 fn admits_with(&self, path: &Path, rule: CaseRule) -> bool {
798 self.canonical
799 .iter()
800 .chain(self.aliases.iter())
801 .any(|root| is_within(path, root, rule))
802 }
803
804 pub(crate) fn contains_resolved_prefix(&self, path: &Path) -> bool {
808 canonicalize_existing_prefix(path, &canonicalize_on_disk)
809 .map_or(true, |canonical| self.contains_canonical(&canonical))
810 }
811
812 pub(crate) fn canonical_root_of(&self, path: &Path) -> Option<&Path> {
814 self.split_under_root(path).map(|(root, _)| root)
815 }
816
817 pub(crate) fn split_under_root<'r, 'p>(
820 &'r self,
821 path: &'p Path,
822 ) -> Option<(&'r Path, &'p Path)> {
823 self.canonical
824 .iter()
825 .map(PathBuf::as_path)
826 .find_map(|root| {
827 strip_root(path, root, CaseRule::Exact).map(|relative| (root, relative))
828 })
829 }
830
831 pub(crate) fn contains_canonical(&self, path: &Path) -> bool {
833 self.canonical_root_of(path).is_some()
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use std::assert_matches;
840
841 use super::*;
842
843 fn abs(path: &str) -> PathBuf {
845 if cfg!(windows) {
846 PathBuf::from(format!("C:{}", path.replace('/', "\\")))
847 } else {
848 PathBuf::from(path)
849 }
850 }
851
852 fn file_uri(path: &str) -> Uri {
853 let prefix = if cfg!(windows) {
854 "file:///C:"
855 } else {
856 "file://"
857 };
858 Uri::from(format!("{prefix}{path}").as_str())
859 }
860
861 fn resolve_in(roots: &[PathBuf], base: &Path) -> Result<WorkspaceRoots, Error> {
862 WorkspaceRoots::from_paths_with(roots, || Ok(ProcessCwd::new(base.to_path_buf(), None)))
863 }
864
865 #[cfg(unix)]
866 fn resolve_in_with_pwd(
867 roots: &[PathBuf],
868 base: &Path,
869 pwd: &Path,
870 ) -> Result<WorkspaceRoots, Error> {
871 WorkspaceRoots::from_paths_with(roots, || {
872 Ok(ProcessCwd::new(
873 base.to_path_buf(),
874 Some(pwd.as_os_str().to_owned()),
875 ))
876 })
877 }
878
879 fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
880 let dir = tempfile::tempdir().unwrap();
881 let base = dunce::canonicalize(dir.path()).unwrap();
882 (dir, base)
883 }
884
885 #[test]
886 fn test_checkout_scoped_drops_the_filesystem_root_and_home() {
887 let (home, project) = (abs("/home/me"), abs("/home/me/project"));
888 let roots = WorkspaceRoots::for_test(vec![abs("/"), home.clone(), project.clone()], vec![]);
889
890 let scoped = roots.checkout_scoped(Some(&home));
891
892 assert_eq!(scoped.canonical(), [project]);
893 assert_eq!(roots.checkout_scoped(None).canonical().len(), 2);
894 }
895
896 #[test]
897 fn test_lexically_normalize_resolves_dots() {
898 assert_eq!(
899 lexically_normalize(Path::new("/ws/./a/../b")),
900 PathBuf::from("/ws/b")
901 );
902 }
903
904 #[test]
905 fn test_lexically_normalize_never_rises_above_root() {
906 assert_eq!(
907 lexically_normalize(Path::new("/ws/../../etc")),
908 PathBuf::from("/etc")
909 );
910 }
911
912 #[test]
913 fn test_from_parts_drops_relative_duplicate_and_canonical_aliases() {
914 let roots = WorkspaceRoots::for_test(
915 vec![abs("/real")],
916 vec![
917 PathBuf::from("relative"),
918 abs("/real"),
919 abs("/alias"),
920 abs("/alias/./"),
921 ],
922 );
923 assert_eq!(roots.aliases.len(), 1);
924 }
925
926 #[test]
927 fn test_admits_lexically_alias_and_canonical() {
928 let roots = WorkspaceRoots::for_test(vec![abs("/real/ws")], vec![abs("/alias/ws")]);
929 assert!(roots.admits_lexically(&abs("/real/ws/a.rs")));
930 assert!(roots.admits_lexically(&abs("/alias/ws/a.rs")));
931 assert!(!roots.admits_lexically(&abs("/other/a.rs")));
932 assert!(!roots.admits_lexically(&abs("/real/wsx/a.rs")));
933 }
934
935 #[test]
936 fn test_empty_roots_admit_nothing() {
937 let roots = WorkspaceRoots::default();
938 assert!(roots.is_empty());
939 assert!(!roots.admits_lexically(Path::new("/a")));
940 assert!(!roots.admits_uri(&file_uri("/anywhere/at/all.rs")));
941 }
942
943 #[test]
944 fn test_admits_uri_under_root_and_alias() {
945 let roots = WorkspaceRoots::for_test(vec![abs("/real/ws")], vec![abs("/alias/ws")]);
946 assert!(roots.admits_uri(&file_uri("/real/ws/src/main.rs")));
947 assert!(roots.admits_uri(&file_uri("/alias/ws/src/main.rs")));
948 }
949
950 #[test]
951 fn test_admits_uri_rejects_outside_sibling_and_non_file() {
952 let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
953 assert!(!roots.admits_uri(&file_uri("/etc/passwd")));
954 assert!(!roots.admits_uri(&file_uri("/ws2/a.rs")));
955 assert!(!roots.admits_uri(&Uri::from("untitled:Untitled-1")));
956 }
957
958 #[test]
961 fn test_admits_uri_rejects_dot_components() {
962 let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
963 assert!(!roots.admits_uri(&file_uri("/ws/../../etc/passwd")));
964 }
965
966 #[test]
967 fn test_canonicalize_existing_prefix_handles_missing_tail() {
968 let dir = tempfile::tempdir().unwrap();
969 let canonical = dunce::canonicalize(dir.path()).unwrap();
970 let result =
971 canonicalize_existing_prefix(&dir.path().join("gone/x.rs"), &canonicalize_on_disk)
972 .unwrap();
973 assert_eq!(result, canonical.join("gone/x.rs"));
974 }
975
976 #[test]
977 fn test_contains_canonical_is_case_exact() {
978 let roots = WorkspaceRoots::for_test(vec![PathBuf::from("/Real")], vec![]);
979 assert!(roots.contains_canonical(Path::new("/Real/a")));
980 assert!(!roots.contains_canonical(Path::new("/real/a")));
981 }
982
983 #[cfg(windows)]
984 #[test]
985 fn test_verbatim_unc_equals_unc() {
986 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\server\share\ws")], vec![]);
987 assert!(roots.contains_canonical(Path::new(r"\\?\UNC\server\share\ws\a.rs")));
988 assert!(!roots.contains_canonical(Path::new(r"\\?\UNC\server\other\ws\a.rs")));
989 }
990
991 #[cfg(windows)]
992 #[test]
993 fn test_verbatim_disk_equals_disk() {
994 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"C:\ws")], vec![]);
995 assert!(roots.contains_canonical(Path::new(r"\\?\C:\ws\a.rs")));
996 }
997
998 #[test]
999 fn test_split_under_root_returns_root_and_remainder() {
1000 let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
1001 let file = abs("/ws/sub/a.rs");
1002
1003 let (root, relative) = roots.split_under_root(&file).unwrap();
1004
1005 assert_eq!(root, abs("/ws"));
1006 assert_eq!(relative, Path::new("sub").join("a.rs"));
1007 assert_eq!(
1008 roots.split_under_root(&abs("/ws")).unwrap().1,
1009 Path::new("")
1010 );
1011 assert!(roots.split_under_root(&abs("/wsx/a.rs")).is_none());
1012 assert!(roots.split_under_root(&abs("/other/a.rs")).is_none());
1013 }
1014
1015 #[cfg(windows)]
1016 #[test]
1017 fn test_split_under_root_strips_a_verbatim_spelling_of_the_root() {
1018 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"C:\ws")], vec![]);
1019 let long = format!(r"\\?\C:\ws\{}\a.rs", "d".repeat(300));
1020
1021 let (root, relative) = roots.split_under_root(Path::new(&long)).unwrap();
1022
1023 assert_eq!(root, Path::new(r"C:\ws"));
1024 assert_eq!(relative, Path::new(&format!(r"{}\a.rs", "d".repeat(300))));
1025 }
1026
1027 #[cfg(windows)]
1028 #[test]
1029 fn test_split_under_root_strips_a_verbatim_unc_spelling_of_the_root() {
1030 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\server\share\ws")], vec![]);
1031
1032 let (_, relative) = roots
1033 .split_under_root(Path::new(r"\\?\UNC\server\share\ws\sub\a.rs"))
1034 .unwrap();
1035
1036 assert_eq!(relative, Path::new(r"sub\a.rs"));
1037 }
1038
1039 #[cfg(windows)]
1040 #[test]
1041 fn test_admits_uri_accepts_verbatim_prefix_root() {
1042 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\?\C:\ws")], vec![]);
1043 assert!(roots.admits_uri(&Uri::from("file:///C:/ws/a.rs")));
1044 }
1045
1046 #[test]
1047 fn test_from_configured_absolute_missing_root_keeps_alias() {
1048 let (_dir, base) = canonical_tempdir();
1049 let missing = base.join("missing");
1050 let roots = resolve_in(std::slice::from_ref(&missing), &base).unwrap();
1051 assert_eq!(roots.canonical(), std::slice::from_ref(&missing));
1052 assert!(roots.admits_lexically(&missing.join("a.rs")));
1053 }
1054
1055 #[test]
1056 fn test_from_configured_rejects_nonexistent_relative_path() {
1057 let (_dir, base) = canonical_tempdir();
1058
1059 let err = resolve_in(&[PathBuf::from("missing")], &base).unwrap_err();
1060
1061 let Error::Config(config_err) = err else {
1062 panic!("expected Config, got {err:?}");
1063 };
1064 let message = config_err.to_string();
1065 assert!(message.contains("workspace root 'missing'"));
1066 assert!(message.contains(&base.display().to_string()));
1067 }
1068
1069 #[test]
1070 fn test_from_configured_surfaces_unreadable_cwd_only_when_needed() {
1071 let (_dir, base) = canonical_tempdir();
1072 let broken = || Err(Error::Io(std::io::Error::other("cwd gone")));
1073
1074 assert_matches!(
1075 WorkspaceRoots::from_configured_with(&[], broken),
1076 Err(Error::Io(_))
1077 );
1078 assert_matches!(
1079 WorkspaceRoots::from_paths_with(&[PathBuf::from("rel")], broken),
1080 Err(Error::Io(_))
1081 );
1082 assert!(WorkspaceRoots::from_paths_with(&[base], broken).is_ok());
1083 }
1084
1085 #[cfg(unix)]
1089 #[test]
1090 fn test_from_configured_symlinked_root_keeps_alias() {
1091 let (_dir, base) = canonical_tempdir();
1092 let real = base.join("real");
1093 std::fs::create_dir(&real).unwrap();
1094 let link = base.join("link");
1095 std::os::unix::fs::symlink(&real, &link).unwrap();
1096
1097 let roots = resolve_in(std::slice::from_ref(&link), &base).unwrap();
1098
1099 assert_eq!(roots.canonical(), std::slice::from_ref(&real));
1100 assert!(roots.admits_lexically(&link.join("a.rs")));
1101 assert!(roots.admits_lexically(&real.join("a.rs")));
1102 assert!(!roots.admits_lexically(&base.join("other/a.rs")));
1103 }
1104
1105 #[test]
1106 fn test_from_configured_empty_returns_cwd() {
1107 let (_dir, base) = canonical_tempdir();
1108 let roots = resolve_in(&[], &base).unwrap();
1109 assert_eq!(roots.canonical(), [base]);
1110 }
1111
1112 #[test]
1113 fn test_from_configured_preserves_order_and_resolves_relative() {
1114 let (_dir, base) = canonical_tempdir();
1115 let absolute = base.join("absolute");
1116 std::fs::create_dir(&absolute).unwrap();
1117 std::fs::create_dir_all(base.join("relative/path")).unwrap();
1118
1119 let roots = resolve_in(&[absolute.clone(), PathBuf::from("relative/path")], &base).unwrap();
1120
1121 assert_eq!(roots.canonical(), [absolute, base.join("relative/path")]);
1122 }
1123
1124 #[test]
1125 fn test_from_configured_dot_and_parent() {
1126 let (_dir, parent) = canonical_tempdir();
1127 let nested = parent.join("nested");
1128 std::fs::create_dir(&nested).unwrap();
1129
1130 let dot = resolve_in(&[PathBuf::from(".")], &nested).unwrap();
1131 let up = resolve_in(&[PathBuf::from("..")], &nested).unwrap();
1132
1133 assert_eq!(dot.canonical(), [nested]);
1134 assert_eq!(up.canonical(), [parent]);
1135 }
1136
1137 #[test]
1138 fn test_from_configured_unicode_and_spaces() {
1139 let (_dir, base) = canonical_tempdir();
1140 let config_roots = [
1141 PathBuf::from("workspace/テスト"),
1142 PathBuf::from("workspace/тест"),
1143 PathBuf::from("another path/workspace"),
1144 ];
1145 for root in &config_roots {
1146 std::fs::create_dir_all(base.join(root)).unwrap();
1147 }
1148
1149 let roots = resolve_in(&config_roots, &base).unwrap();
1150
1151 let expected: Vec<PathBuf> = config_roots.iter().map(|r| base.join(r)).collect();
1152 assert_eq!(roots.canonical(), expected);
1153 }
1154
1155 #[test]
1157 fn test_from_configured_ignores_cwd_when_all_absolute() {
1158 let (_dir, base) = canonical_tempdir();
1159 let root = base.join("root");
1160 std::fs::create_dir(&root).unwrap();
1161
1162 let roots = WorkspaceRoots::from_paths_with(std::slice::from_ref(&root), || {
1163 panic!("cwd must not be read")
1164 })
1165 .unwrap();
1166
1167 assert_eq!(roots.canonical(), [root]);
1168 }
1169
1170 #[test]
1174 fn test_join_relative_root_strips_leading_root_and_prefix_components() {
1175 let base = Path::new("/base/dir");
1176
1177 assert_eq!(
1178 join_relative_root(base, Path::new("/workspace")),
1179 PathBuf::from("/base/dir/workspace")
1180 );
1181 assert_eq!(
1182 join_relative_root(base, Path::new("/workspace/sub")),
1183 PathBuf::from("/base/dir/workspace/sub")
1184 );
1185 assert_eq!(
1186 join_relative_root(base, Path::new("workspace")),
1187 PathBuf::from("/base/dir/workspace")
1188 );
1189 assert_eq!(
1190 join_relative_root(base, Path::new("..")),
1191 PathBuf::from("/base/dir/..")
1192 );
1193 }
1194
1195 #[cfg(windows)]
1197 #[test]
1198 fn test_from_configured_windows_root_without_prefix() {
1199 let (_dir, base) = canonical_tempdir();
1200 let nested = base.join("workspace");
1201 std::fs::create_dir(&nested).unwrap();
1202
1203 let root = PathBuf::from(r"\workspace");
1204 assert!(root.is_relative());
1205
1206 let roots = resolve_in(std::slice::from_ref(&root), &base).unwrap();
1207 assert_eq!(roots.canonical(), [nested]);
1208 }
1209
1210 #[cfg(windows)]
1213 #[test]
1214 fn test_from_configured_windows_drive_relative_root() {
1215 let (_dir, base) = canonical_tempdir();
1216 let nested = base.join("workspace");
1217 std::fs::create_dir(&nested).unwrap();
1218
1219 let Some(drive_prefix) = base.components().find_map(|c| match c {
1220 Component::Prefix(p) => Some(p.as_os_str().to_owned()),
1221 _ => None,
1222 }) else {
1223 panic!("temp dir path should have a Windows drive prefix");
1224 };
1225 let mut root = drive_prefix;
1226 root.push("workspace");
1227 let root = PathBuf::from(root);
1228 assert!(root.is_relative());
1229
1230 let roots = resolve_in(std::slice::from_ref(&root), &base).unwrap();
1231 assert_eq!(roots.canonical(), [nested]);
1232 }
1233
1234 #[cfg(unix)]
1235 #[test]
1236 fn test_process_cwd_accepts_pwd_only_when_it_names_the_cwd() {
1237 let (_dir, base) = canonical_tempdir();
1238 let link = base.join("link");
1239 std::os::unix::fs::symlink(&base, &link).unwrap();
1240 let other = tempfile::TempDir::new().unwrap();
1241 let logical = |pwd: Option<&std::ffi::OsStr>| {
1242 ProcessCwd::new(base.clone(), pwd.map(ToOwned::to_owned)).logical
1243 };
1244
1245 assert_eq!(logical(Some(link.as_os_str())), Some(link));
1246 assert_eq!(logical(Some(other.path().as_os_str())), None);
1247 assert_eq!(logical(Some(std::ffi::OsStr::new("."))), None);
1248 assert_eq!(logical(None), None);
1249 }
1250
1251 #[cfg(unix)]
1252 #[test]
1253 fn test_from_configured_relative_root_admits_logical_cwd_spelling() {
1254 let (_dir, base) = canonical_tempdir();
1255 let real = base.join("real");
1256 std::fs::create_dir_all(real.join("rel")).unwrap();
1257 let link = base.join("link");
1258 std::os::unix::fs::symlink(&real, &link).unwrap();
1259
1260 let roots = resolve_in_with_pwd(&[PathBuf::from("rel")], &real, &link).unwrap();
1261 assert!(roots.admits_lexically(&link.join("rel/f.rs")));
1262
1263 let forged = resolve_in_with_pwd(&[PathBuf::from("rel")], &real, &base).unwrap();
1264 assert!(!forged.admits_lexically(&link.join("rel/f.rs")));
1265 }
1266
1267 #[cfg(unix)]
1268 #[test]
1269 fn test_from_configured_empty_roots_admit_logical_cwd_spelling() {
1270 let (_dir, base) = canonical_tempdir();
1271 let real = base.join("real");
1272 std::fs::create_dir(&real).unwrap();
1273 let link = base.join("link");
1274 std::os::unix::fs::symlink(&real, &link).unwrap();
1275
1276 let roots = resolve_in_with_pwd(&[], &real, &link).unwrap();
1277
1278 assert_eq!(roots.canonical(), [real]);
1279 assert!(roots.admits_lexically(&link.join("f.rs")));
1280 }
1281
1282 #[cfg(unix)]
1285 #[test]
1286 fn test_from_configured_drops_alias_that_climbs_out_through_symlink() {
1287 let (_dir, base) = canonical_tempdir();
1288 let data = base.join("data/a/b");
1289 std::fs::create_dir_all(&data).unwrap();
1290 let home = base.join("home");
1291 std::fs::create_dir(&home).unwrap();
1292 let link = home.join("proj_link");
1293 std::os::unix::fs::symlink(&data, &link).unwrap();
1294
1295 let roots = resolve_in(&[link.join("..")], &base).unwrap();
1296
1297 assert_eq!(roots.canonical(), [base.join("data/a")]);
1298 assert!(roots.aliases.iter().all(|alias| !alias.starts_with(&home)));
1299 assert!(!roots.admits_lexically(&home.join("anything")));
1300 }
1301
1302 #[cfg(unix)]
1303 #[test]
1304 fn test_from_configured_keeps_alias_under_symlinked_ancestor() {
1305 let (_dir, base) = canonical_tempdir();
1306 let real = base.join("real");
1307 std::fs::create_dir_all(real.join("sub")).unwrap();
1308 let link = base.join("link");
1309 std::os::unix::fs::symlink(&real, &link).unwrap();
1310 let configured = link.join("sub");
1311
1312 let roots = resolve_in(std::slice::from_ref(&configured), &base).unwrap();
1313
1314 assert_eq!(roots.canonical(), [real.join("sub")]);
1315 assert!(roots.admits_lexically(&configured.join("a.rs")));
1316 }
1317
1318 #[cfg(not(unix))]
1319 #[test]
1320 fn test_from_configured_keeps_absolute_root_as_alias() {
1321 let dir = tempfile::tempdir().unwrap();
1322 let raw = dir.path().to_path_buf();
1323
1324 let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&raw)).unwrap();
1325
1326 assert!(roots.admits_lexically(&raw.join("a.rs")));
1327 }
1328
1329 #[cfg(unix)]
1330 fn fake_root() -> (tempfile::TempDir, PathBuf) {
1331 let dir = tempfile::tempdir().unwrap();
1332 let base = dunce::canonicalize(dir.path()).unwrap();
1333 (dir, base)
1334 }
1335
1336 #[cfg(unix)]
1337 #[test]
1338 fn test_system_aliases_symlink_table() {
1339 let (_guard, base) = fake_root();
1340 let real = base.join("private/tmp/proj");
1341 std::fs::create_dir_all(&real).unwrap();
1342 std::os::unix::fs::symlink(base.join("private/tmp"), base.join("tmp")).unwrap();
1343 std::os::unix::fs::symlink(base.join("private/var"), base.join("var")).unwrap();
1344 std::os::unix::fs::symlink("private/tmp", base.join("rel")).unwrap();
1345
1346 let roots =
1347 WorkspaceRoots::for_test(vec![real.clone()], vec![]).with_system_aliases_in(&base);
1348
1349 assert!(roots.admits_lexically(&base.join("tmp/proj/a.rs")));
1350 assert!(roots.admits_lexically(&base.join("rel/proj/a.rs")));
1351 assert!(!roots.admits_lexically(&base.join("var/proj/a.rs")));
1352 assert!(!roots.admits_lexically(&base.join("tmp/other/a.rs")));
1353 assert!(roots.contains_canonical(&real.join("a.rs")));
1354 }
1355
1356 #[cfg(unix)]
1357 #[test]
1358 fn test_system_aliases_reject_forged_alias() {
1359 let (_guard, base) = fake_root();
1360 let real = base.join("p/b/proj");
1361 std::fs::create_dir_all(&real).unwrap();
1362 std::fs::create_dir_all(base.join("elsewhere/deep")).unwrap();
1363 std::fs::create_dir_all(base.join("elsewhere/b/proj")).unwrap();
1364 std::os::unix::fs::symlink(base.join("elsewhere/deep"), base.join("p/a")).unwrap();
1365 std::os::unix::fs::symlink("p/a/../b", base.join("x")).unwrap();
1367
1368 let roots = WorkspaceRoots::for_test(vec![real], vec![]).with_system_aliases_in(&base);
1369
1370 assert!(!roots.admits_lexically(&base.join("x/proj/a.rs")));
1371 }
1372
1373 #[cfg(unix)]
1374 #[test]
1375 fn test_system_aliases_ignore_file_dangling_and_looping_links() {
1376 let (_guard, base) = fake_root();
1377 let real = base.join("gone/proj");
1378 let file = base.join("file");
1379 std::fs::write(&file, "").unwrap();
1380 std::os::unix::fs::symlink(&file, base.join("to_file")).unwrap();
1381 std::os::unix::fs::symlink(base.join("gone"), base.join("dangling")).unwrap();
1382 std::os::unix::fs::symlink("loop_b", base.join("loop_a")).unwrap();
1383 std::os::unix::fs::symlink("loop_a", base.join("loop_b")).unwrap();
1384
1385 let roots = WorkspaceRoots::for_test(vec![real], vec![]).with_system_aliases_in(&base);
1386
1387 assert!(roots.aliases.is_empty());
1388 }
1389
1390 #[cfg(unix)]
1391 #[test]
1392 fn test_system_aliases_unreadable_dir_yields_none() {
1393 let (_guard, base) = fake_root();
1394 let roots = WorkspaceRoots::for_test(vec![base.join("r")], vec![])
1395 .with_system_aliases_in(&base.join("missing"));
1396 assert!(roots.aliases.is_empty());
1397 }
1398
1399 #[cfg(unix)]
1400 #[test]
1401 fn test_system_aliases_dotdot_and_sibling_rejected_without_stat() {
1402 let roots = WorkspaceRoots::for_test(
1403 vec![PathBuf::from("/private/tmp/proj")],
1404 vec![PathBuf::from("/tmp/proj")],
1405 );
1406 assert!(roots.admits_lexically(&PathBuf::from("/tmp/proj/a.rs")));
1407 assert!(!roots.admits_lexically(&lexically_normalize(Path::new("/tmp/proj/../other/a"))));
1408 assert!(!roots.admits_lexically(Path::new("/tmp/projx/a.rs")));
1409 }
1410
1411 #[cfg(target_os = "macos")]
1412 #[test]
1413 fn test_system_aliases_real_tmp_on_macos() {
1414 let dir = tempfile::Builder::new().tempdir_in("/tmp").unwrap();
1415 let canonical = dunce::canonicalize(dir.path()).unwrap();
1416 assert!(canonical.starts_with("/private/tmp"));
1417
1418 let roots = WorkspaceRoots::for_test(vec![canonical], vec![]).with_system_aliases();
1419
1420 assert!(roots.admits_lexically(&dir.path().join("a.rs")));
1421 assert!(!roots.admits_lexically(Path::new("/tmp/other-dir/a.rs")));
1422 }
1423
1424 #[cfg(unix)]
1425 #[tokio::test]
1426 async fn test_admits_edit_uri_refuses_a_path_under_an_unreadable_ancestor() {
1427 use std::os::unix::fs::PermissionsExt;
1428
1429 let (_dir, base) = canonical_tempdir();
1430 let probe = base.join("probe");
1431 std::fs::create_dir(&probe).unwrap();
1432 std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
1433 let root_bypasses_modes = std::fs::read_dir(&probe).is_ok();
1434 std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o755)).unwrap();
1435 if root_bypasses_modes {
1436 return;
1437 }
1438 let sealed = base.join("sealed");
1439 std::fs::create_dir(&sealed).unwrap();
1440 let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&base)).unwrap();
1441 let uri = Uri::from(format!("file://{}", sealed.join("new.rs").display()).as_str());
1442 assert!(roots.admits_edit_uri(&uri).await);
1443
1444 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o000)).unwrap();
1445 let admitted = roots.admits_edit_uri(&uri).await;
1446 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
1447
1448 assert!(!admitted, "an unreadable ancestor must not be skipped over");
1449 }
1450
1451 #[cfg(unix)]
1452 #[tokio::test]
1453 async fn test_admits_edit_uri_refuses_a_dangling_symlink() {
1454 let (_dir, base) = canonical_tempdir();
1455 let outside = tempfile::tempdir().unwrap();
1456 let target = dunce::canonicalize(outside.path()).unwrap().join("x");
1457 std::os::unix::fs::symlink(&target, base.join("evil.rs")).unwrap();
1458 std::os::unix::fs::symlink(outside.path().join("nodir"), base.join("evil_dir")).unwrap();
1459 let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&base)).unwrap();
1460
1461 for name in ["evil.rs", "evil_dir/file.rs"] {
1462 let uri = Uri::from(format!("file://{}", base.join(name).display()).as_str());
1463 assert!(!roots.admits_edit_uri(&uri).await, "{name}");
1464 }
1465 assert!(!target.exists());
1466 }
1467}