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
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum CaseRule {
24 Exact,
25 FoldAscii,
26}
27
28impl CaseRule {
29 const LEXICAL: Self = if cfg!(any(windows, target_os = "macos")) {
32 Self::FoldAscii
33 } else {
34 Self::Exact
35 };
36
37 fn components_eq(self, a: Component<'_>, b: Component<'_>) -> bool {
38 match (a, b) {
39 (Component::Prefix(a), Component::Prefix(b)) => prefix_eq(a, b),
40 _ => match self {
41 Self::Exact => a == b,
42 Self::FoldAscii => a
43 .as_os_str()
44 .to_string_lossy()
45 .eq_ignore_ascii_case(&b.as_os_str().to_string_lossy()),
46 },
47 }
48 }
49}
50
51fn prefix_eq(a: PrefixComponent<'_>, b: PrefixComponent<'_>) -> bool {
55 let same_name = |x: &std::ffi::OsStr, y: &std::ffi::OsStr| {
56 x.to_string_lossy()
57 .eq_ignore_ascii_case(&y.to_string_lossy())
58 };
59 match (a.kind(), b.kind()) {
60 (Prefix::Disk(x) | Prefix::VerbatimDisk(x), Prefix::Disk(y) | Prefix::VerbatimDisk(y)) => {
61 x.eq_ignore_ascii_case(&y)
62 }
63 (
64 Prefix::UNC(server_a, share_a) | Prefix::VerbatimUNC(server_a, share_a),
65 Prefix::UNC(server_b, share_b) | Prefix::VerbatimUNC(server_b, share_b),
66 ) => same_name(server_a, server_b) && same_name(share_a, share_b),
67 _ => a == b,
68 }
69}
70
71fn is_within(path: &Path, root: &Path, rule: CaseRule) -> bool {
72 let mut components = path.components();
73 root.components()
74 .all(|r| components.next().is_some_and(|c| rule.components_eq(c, r)))
75}
76
77pub fn lexically_normalize(path: &Path) -> PathBuf {
80 let mut parts: Vec<Component<'_>> = Vec::new();
81 for component in path.components() {
82 match component {
83 Component::CurDir => {}
84 Component::ParentDir => match parts.last() {
85 Some(Component::Normal(_)) => {
86 parts.pop();
87 }
88 Some(Component::RootDir | Component::Prefix(_)) => {}
89 _ => parts.push(component),
90 },
91 other => parts.push(other),
92 }
93 }
94 parts.iter().collect()
95}
96
97pub type CanonicalizeFn = dyn Fn(&Path) -> io::Result<PathBuf> + Send + Sync;
100
101#[derive(Debug, ThisError)]
103pub enum Unresolved {
104 #[error("transient filesystem error: {0}")]
106 Transient(#[source] io::Error),
107 #[error("no ancestor of the path exists")]
109 NoExistingAncestor,
110 #[error("path contains a `..` component")]
113 ParentComponent,
114}
115
116fn canonicalize_on_disk(path: &Path) -> io::Result<PathBuf> {
120 dunce::canonicalize(path).map_err(|error| {
121 if error.kind() == io::ErrorKind::NotFound && std::fs::symlink_metadata(path).is_ok() {
122 io::Error::other("dangling symlink")
123 } else {
124 error
125 }
126 })
127}
128
129pub fn canonicalize_existing_prefix(
141 path: &Path,
142 canonicalize: &CanonicalizeFn,
143) -> Result<PathBuf, Unresolved> {
144 if path.components().any(|c| matches!(c, Component::ParentDir)) {
145 return Err(Unresolved::ParentComponent);
146 }
147 for ancestor in path.ancestors() {
148 match canonicalize(ancestor) {
149 Ok(canonical) => {
150 return path
151 .strip_prefix(ancestor)
152 .map(|rest| lexically_normalize(&canonical.join(rest)))
153 .map_err(|_| Unresolved::NoExistingAncestor);
154 }
155 Err(e)
156 if matches!(
157 e.kind(),
158 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
159 ) => {}
160 Err(e) => return Err(Unresolved::Transient(e)),
161 }
162 }
163 Err(Unresolved::NoExistingAncestor)
164}
165
166fn path_io_error(path: &Path, source: io::Error) -> Error {
169 match source.kind() {
170 io::ErrorKind::NotADirectory
171 | io::ErrorKind::InvalidFilename
172 | io::ErrorKind::InvalidInput => Error::MalformedPath {
173 path: path.to_path_buf(),
174 source,
175 },
176 _ => Error::FileIo {
177 path: path.to_path_buf(),
178 source,
179 },
180 }
181}
182
183#[derive(Debug, Clone)]
189pub struct ProcessCwd {
190 physical: PathBuf,
191 logical: Option<PathBuf>,
192}
193
194impl ProcessCwd {
195 pub(crate) fn current() -> Result<Self, Error> {
197 let physical = std::env::current_dir().map_err(Error::Io)?;
198 Ok(Self::new(physical, std::env::var_os("PWD")))
199 }
200
201 pub(crate) fn new(physical: PathBuf, pwd: Option<OsString>) -> Self {
205 let logical = pwd.map(PathBuf::from).filter(|pwd| {
206 pwd.is_absolute()
207 && matches!(
208 (dunce::canonicalize(pwd), dunce::canonicalize(&physical)),
209 (Ok(a), Ok(b)) if a == b
210 )
211 });
212 Self { physical, logical }
213 }
214}
215
216pub fn probe_root(written: &Path, base_dir: &Path, probe: &Path) -> Result<PathBuf, Error> {
224 dunce::canonicalize(probe).map_err(|source| {
225 ConfigError::UnresolvableWorkspaceRoot {
226 written: written.to_path_buf(),
227 base_dir: base_dir.to_path_buf(),
228 probe: probe.to_path_buf(),
229 source,
230 }
231 .into()
232 })
233}
234
235pub fn join_relative_root(base_dir: &Path, root: &Path) -> PathBuf {
252 let mut joined = base_dir.to_path_buf();
253 joined.extend(
254 root.components()
255 .skip_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)),
256 );
257 joined
258}
259
260fn plain_absolute_path(uri: &Uri) -> Option<PathBuf> {
263 let path = uri_to_path(uri)?;
264 let plain = path.is_absolute()
265 && !path
266 .components()
267 .any(|c| matches!(c, Component::CurDir | Component::ParentDir));
268 plain.then_some(path)
269}
270
271fn stored_alias_form(alias: &Path) -> Option<PathBuf> {
274 if !alias.is_absolute() {
275 return None;
276 }
277 let form = lexically_normalize(dunce::simplified(alias));
278 (!form.is_empty()).then_some(form)
279}
280
281struct ResolvedRoot {
284 canonical: PathBuf,
285 aliases: Vec<PathBuf>,
286}
287
288impl ResolvedRoot {
289 fn from_absolute(root: &Path) -> Self {
290 let canonical = dunce::canonicalize(root).unwrap_or_else(|source| {
291 warn!(
292 "Failed to canonicalize absolute workspace root {}: {source}, using non-canonical path",
293 root.display()
294 );
295 canonicalize_existing_prefix(root, &canonicalize_on_disk)
296 .unwrap_or_else(|_| root.to_path_buf())
297 });
298 Self {
299 canonical,
300 aliases: vec![root.to_path_buf()],
301 }
302 }
303
304 fn from_relative(root: &Path, cwd: &ProcessCwd) -> Result<Self, Error> {
305 let joined = join_relative_root(&cwd.physical, root);
306 let canonical = probe_root(root, &cwd.physical, &joined)?;
307 let mut aliases = vec![joined];
308 aliases.extend(
309 cwd.logical
310 .as_deref()
311 .map(|logical| join_relative_root(logical, root)),
312 );
313 Ok(Self { canonical, aliases })
314 }
315
316 fn from_cwd(cwd: &ProcessCwd) -> Self {
317 let canonical = dunce::canonicalize(&cwd.physical).unwrap_or_else(|e| {
318 warn!(
319 "Failed to canonicalize workspace base directory {}: {e}, using non-canonical absolute path",
320 cwd.physical.display()
321 );
322 cwd.physical.clone()
323 });
324 info!(
325 "Using workspace base directory as root: {}",
326 canonical.display()
327 );
328 let mut aliases = vec![cwd.physical.clone()];
329 aliases.extend(cwd.logical.clone());
330 Self { canonical, aliases }
331 }
332
333 fn verified_aliases(&self) -> impl Iterator<Item = PathBuf> + '_ {
337 self.aliases.iter().filter_map(|alias| {
338 let form = stored_alias_form(alias)?;
339 if form == self.canonical
340 || canonicalize_existing_prefix(&form, &canonicalize_on_disk)
341 .is_ok_and(|real| real == self.canonical)
342 {
343 Some(form)
344 } else {
345 debug!(
346 "dropping workspace root alias {} that does not resolve to {}",
347 form.display(),
348 self.canonical.display()
349 );
350 None
351 }
352 })
353 }
354}
355
356#[cfg(unix)]
359#[derive(Debug, Clone, PartialEq, Eq)]
360struct RootLink {
361 link: PathBuf,
362 target: PathBuf,
363}
364
365#[cfg(unix)]
369fn read_root_links(links_dir: &Path) -> Vec<RootLink> {
370 let entries = match std::fs::read_dir(links_dir) {
371 Ok(entries) => entries,
372 Err(err) => {
373 tracing::debug!(dir = %links_dir.display(), %err, "cannot list root-level links");
374 return Vec::new();
375 }
376 };
377 entries
378 .filter_map(Result::ok)
379 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_symlink()))
380 .filter_map(|entry| {
381 let link = entry.path();
382 let target = std::fs::read_link(&link).ok()?;
383 Some(RootLink {
384 target: lexically_normalize(&links_dir.join(target)),
385 link,
386 })
387 })
388 .collect()
389}
390
391#[cfg(unix)]
395fn system_symlink_aliases(roots: &[PathBuf], links: &[RootLink]) -> Vec<(PathBuf, PathBuf)> {
396 let mut candidates = Vec::new();
397 for root in roots {
398 for RootLink { link, target } in links {
399 if let Ok(rest) = root.strip_prefix(target) {
400 candidates.push((link.join(rest), root.clone()));
401 }
402 }
403 }
404 candidates
405}
406
407#[cfg(unix)]
410fn verified_system_aliases(links_dir: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
411 system_symlink_aliases(roots, &read_root_links(links_dir))
412 .into_iter()
413 .filter(|(alias, root)| dunce::canonicalize(alias).is_ok_and(|real| &real == root))
414 .map(|(alias, _)| alias)
415 .collect()
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Hash)]
439pub struct WorkspacePath(PathBuf);
440
441impl WorkspacePath {
442 #[must_use]
444 pub fn as_path(&self) -> &Path {
445 &self.0
446 }
447
448 #[must_use]
450 pub fn into_path_buf(self) -> PathBuf {
451 self.0
452 }
453}
454
455impl AsRef<Path> for WorkspacePath {
456 fn as_ref(&self) -> &Path {
457 &self.0
458 }
459}
460
461#[derive(Debug, Clone, Default)]
472pub struct WorkspaceRoots {
473 canonical: Arc<[PathBuf]>,
474 aliases: Arc<[PathBuf]>,
475}
476
477impl WorkspaceRoots {
478 fn from_parts(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
484 let mut kept: Vec<PathBuf> = Vec::new();
485 for alias in aliases {
486 let Some(alias) = stored_alias_form(&alias) else {
487 continue;
488 };
489 if canonical.contains(&alias) || kept.contains(&alias) {
490 continue;
491 }
492 kept.push(alias);
493 }
494 Self {
495 canonical: canonical.into(),
496 aliases: kept.into(),
497 }
498 }
499
500 pub(crate) fn untrusted_boundary(
504 &self,
505 configured: &[ConfiguredRoot],
506 home: Option<&Path>,
507 ) -> Self {
508 if configured.is_empty() {
509 self.checkout_scoped(home)
510 } else {
511 self.clone()
512 }
513 }
514
515 pub(crate) fn checkout_scoped(&self, home: Option<&Path>) -> Self {
523 let scoped = |path: &Path| path.parent().is_some() && home != Some(path);
524 Self::from_parts(
525 self.canonical
526 .iter()
527 .filter(|r| scoped(r))
528 .cloned()
529 .collect(),
530 self.aliases.iter().filter(|a| scoped(a)).cloned().collect(),
531 )
532 }
533
534 #[cfg(test)]
537 pub(crate) fn for_test(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
538 Self::from_parts(canonical, aliases)
539 }
540
541 pub fn from_configured(roots: &[ConfiguredRoot]) -> Result<Self, Error> {
573 Self::from_configured_with(roots, ProcessCwd::current)
574 }
575
576 #[cfg(test)]
578 pub(crate) fn from_paths<P: AsRef<Path>>(roots: &[P]) -> Result<Self, Error> {
579 Self::from_paths_with(roots, ProcessCwd::current)
580 }
581
582 #[cfg(test)]
584 pub(crate) fn from_paths_with<P: AsRef<Path>>(
585 roots: &[P],
586 cwd: impl FnOnce() -> Result<ProcessCwd, Error>,
587 ) -> Result<Self, Error> {
588 let roots: Vec<ConfiguredRoot> = roots
589 .iter()
590 .map(|root| ConfiguredRoot::new(root.as_ref()).expect("test root"))
591 .collect();
592 Self::from_configured_with(&roots, cwd)
593 }
594
595 pub(crate) fn from_configured_with(
596 roots: &[ConfiguredRoot],
597 cwd: impl FnOnce() -> Result<ProcessCwd, Error>,
598 ) -> Result<Self, Error> {
599 let needs_cwd = roots.is_empty() || roots.iter().any(|root| root.as_path().is_relative());
600 let cwd = needs_cwd.then(cwd).transpose()?;
601
602 let resolved = match &cwd {
603 Some(cwd) if roots.is_empty() => vec![ResolvedRoot::from_cwd(cwd)],
604 _ => roots
605 .iter()
606 .map(|root| match &cwd {
607 Some(cwd) if root.as_path().is_relative() => {
608 ResolvedRoot::from_relative(root.as_path(), cwd)
609 }
610 _ => Ok(ResolvedRoot::from_absolute(root.as_path())),
611 })
612 .collect::<Result<Vec<_>, Error>>()?,
613 };
614
615 let aliases = resolved
616 .iter()
617 .flat_map(ResolvedRoot::verified_aliases)
618 .collect();
619 let canonical = resolved.into_iter().map(|root| root.canonical).collect();
620 Ok(Self::from_parts(canonical, aliases).with_system_aliases())
621 }
622
623 #[cfg(unix)]
628 #[must_use]
629 fn with_system_aliases(self) -> Self {
630 self.with_system_aliases_in(Path::new("/"))
631 }
632
633 #[cfg(not(unix))]
635 #[must_use]
636 const fn with_system_aliases(self) -> Self {
637 self
638 }
639
640 #[cfg(unix)]
641 fn with_system_aliases_in(self, links_dir: &Path) -> Self {
642 let extra = verified_system_aliases(links_dir, &self.canonical);
643 let aliases = self.aliases.iter().cloned().chain(extra).collect();
644 Self::from_parts(self.canonical.to_vec(), aliases)
645 }
646
647 pub async fn validate(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
657 self.lexical_gate(path)?;
658 let roots = self.clone();
659 let path = path.clone();
660 tokio::task::spawn_blocking(move || roots.canonical_gate(&path))
661 .await
662 .map_err(|source| Error::TaskFailed {
663 task: BackgroundTask::PathValidation,
664 source,
665 })?
666 }
667
668 pub fn validate_blocking(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
690 self.lexical_gate(path)?;
691 self.canonical_gate(path)
692 }
693
694 fn lexical_gate(&self, path: &ClientPath) -> Result<(), Error> {
695 let path = path.as_path();
696 if self.is_empty() {
697 return Err(Error::NoWorkspaceRoots(path.to_path_buf()));
698 }
699 let absolute = std::path::absolute(path).map_err(|source| path_io_error(path, source))?;
700 let normalized = lexically_normalize(dunce::simplified(&absolute));
701 self.admits_lexically(&normalized)
702 .ok_or_else(|| Error::PathOutsideWorkspace(path.to_path_buf()))
703 }
704
705 fn canonical_gate(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
706 let path = path.as_path();
707 let canonical = dunce::canonicalize(path).map_err(|source| path_io_error(path, source))?;
708 if self.contains_canonical(&canonical) {
709 Ok(WorkspacePath(canonical))
710 } else {
711 Err(Error::PathOutsideWorkspace(path.to_path_buf()))
712 }
713 }
714
715 #[must_use]
717 pub fn canonical(&self) -> &[PathBuf] {
718 &self.canonical
719 }
720
721 #[must_use]
723 pub fn is_empty(&self) -> bool {
724 self.canonical.is_empty()
725 }
726
727 pub(crate) fn admits_uri(&self, uri: &Uri) -> bool {
742 plain_absolute_path(uri).is_some_and(|path| self.admits_lexically(&path))
743 }
744
745 pub(crate) async fn admits_edit_uri(&self, uri: &Uri) -> bool {
754 let Some(path) = plain_absolute_path(uri) else {
755 return false;
756 };
757 if !self.admits_with(&path, CaseRule::Exact) {
758 return false;
759 }
760 match tokio::task::spawn_blocking(move || {
761 canonicalize_existing_prefix(&path, &canonicalize_on_disk)
762 })
763 .await
764 {
765 Ok(canonical) => canonical.is_ok_and(|canonical| self.contains_canonical(&canonical)),
766 Err(error) => {
767 tracing::warn!(%error, uri = uri.as_ref(), "edit URI canonicalization task failed; dropping the edit");
768 false
769 }
770 }
771 }
772
773 pub(crate) fn admits_lexically(&self, normalized: &Path) -> bool {
777 self.admits_with(normalized, CaseRule::LEXICAL)
778 }
779
780 fn admits_with(&self, path: &Path, rule: CaseRule) -> bool {
781 self.canonical
782 .iter()
783 .chain(self.aliases.iter())
784 .any(|root| is_within(path, root, rule))
785 }
786
787 pub(crate) fn contains_resolved_prefix(&self, path: &Path) -> bool {
791 canonicalize_existing_prefix(path, &canonicalize_on_disk)
792 .map_or(true, |canonical| self.contains_canonical(&canonical))
793 }
794
795 pub(crate) fn contains_canonical(&self, path: &Path) -> bool {
797 self.canonical
798 .iter()
799 .any(|root| is_within(path, root, CaseRule::Exact))
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use std::assert_matches;
806
807 use super::*;
808
809 fn abs(path: &str) -> PathBuf {
811 if cfg!(windows) {
812 PathBuf::from(format!("C:{}", path.replace('/', "\\")))
813 } else {
814 PathBuf::from(path)
815 }
816 }
817
818 fn file_uri(path: &str) -> Uri {
819 let prefix = if cfg!(windows) {
820 "file:///C:"
821 } else {
822 "file://"
823 };
824 Uri::from(format!("{prefix}{path}").as_str())
825 }
826
827 fn resolve_in(roots: &[PathBuf], base: &Path) -> Result<WorkspaceRoots, Error> {
828 WorkspaceRoots::from_paths_with(roots, || Ok(ProcessCwd::new(base.to_path_buf(), None)))
829 }
830
831 #[cfg(unix)]
832 fn resolve_in_with_pwd(
833 roots: &[PathBuf],
834 base: &Path,
835 pwd: &Path,
836 ) -> Result<WorkspaceRoots, Error> {
837 WorkspaceRoots::from_paths_with(roots, || {
838 Ok(ProcessCwd::new(
839 base.to_path_buf(),
840 Some(pwd.as_os_str().to_owned()),
841 ))
842 })
843 }
844
845 fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
846 let dir = tempfile::tempdir().unwrap();
847 let base = dunce::canonicalize(dir.path()).unwrap();
848 (dir, base)
849 }
850
851 #[test]
852 fn test_checkout_scoped_drops_the_filesystem_root_and_home() {
853 let (home, project) = (abs("/home/me"), abs("/home/me/project"));
854 let roots = WorkspaceRoots::for_test(vec![abs("/"), home.clone(), project.clone()], vec![]);
855
856 let scoped = roots.checkout_scoped(Some(&home));
857
858 assert_eq!(scoped.canonical(), [project]);
859 assert_eq!(roots.checkout_scoped(None).canonical().len(), 2);
860 }
861
862 #[test]
863 fn test_lexically_normalize_resolves_dots() {
864 assert_eq!(
865 lexically_normalize(Path::new("/ws/./a/../b")),
866 PathBuf::from("/ws/b")
867 );
868 }
869
870 #[test]
871 fn test_lexically_normalize_never_rises_above_root() {
872 assert_eq!(
873 lexically_normalize(Path::new("/ws/../../etc")),
874 PathBuf::from("/etc")
875 );
876 }
877
878 #[test]
879 fn test_from_parts_drops_relative_duplicate_and_canonical_aliases() {
880 let roots = WorkspaceRoots::for_test(
881 vec![abs("/real")],
882 vec![
883 PathBuf::from("relative"),
884 abs("/real"),
885 abs("/alias"),
886 abs("/alias/./"),
887 ],
888 );
889 assert_eq!(roots.aliases.len(), 1);
890 }
891
892 #[test]
893 fn test_admits_lexically_alias_and_canonical() {
894 let roots = WorkspaceRoots::for_test(vec![abs("/real/ws")], vec![abs("/alias/ws")]);
895 assert!(roots.admits_lexically(&abs("/real/ws/a.rs")));
896 assert!(roots.admits_lexically(&abs("/alias/ws/a.rs")));
897 assert!(!roots.admits_lexically(&abs("/other/a.rs")));
898 assert!(!roots.admits_lexically(&abs("/real/wsx/a.rs")));
899 }
900
901 #[test]
902 fn test_empty_roots_admit_nothing() {
903 let roots = WorkspaceRoots::default();
904 assert!(roots.is_empty());
905 assert!(!roots.admits_lexically(Path::new("/a")));
906 assert!(!roots.admits_uri(&file_uri("/anywhere/at/all.rs")));
907 }
908
909 #[test]
910 fn test_admits_uri_under_root_and_alias() {
911 let roots = WorkspaceRoots::for_test(vec![abs("/real/ws")], vec![abs("/alias/ws")]);
912 assert!(roots.admits_uri(&file_uri("/real/ws/src/main.rs")));
913 assert!(roots.admits_uri(&file_uri("/alias/ws/src/main.rs")));
914 }
915
916 #[test]
917 fn test_admits_uri_rejects_outside_sibling_and_non_file() {
918 let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
919 assert!(!roots.admits_uri(&file_uri("/etc/passwd")));
920 assert!(!roots.admits_uri(&file_uri("/ws2/a.rs")));
921 assert!(!roots.admits_uri(&Uri::from("untitled:Untitled-1")));
922 }
923
924 #[test]
927 fn test_admits_uri_rejects_dot_components() {
928 let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
929 assert!(!roots.admits_uri(&file_uri("/ws/../../etc/passwd")));
930 }
931
932 #[test]
933 fn test_canonicalize_existing_prefix_handles_missing_tail() {
934 let dir = tempfile::tempdir().unwrap();
935 let canonical = dunce::canonicalize(dir.path()).unwrap();
936 let result =
937 canonicalize_existing_prefix(&dir.path().join("gone/x.rs"), &canonicalize_on_disk)
938 .unwrap();
939 assert_eq!(result, canonical.join("gone/x.rs"));
940 }
941
942 #[test]
943 fn test_contains_canonical_is_case_exact() {
944 let roots = WorkspaceRoots::for_test(vec![PathBuf::from("/Real")], vec![]);
945 assert!(roots.contains_canonical(Path::new("/Real/a")));
946 assert!(!roots.contains_canonical(Path::new("/real/a")));
947 }
948
949 #[cfg(windows)]
950 #[test]
951 fn test_verbatim_unc_equals_unc() {
952 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\server\share\ws")], vec![]);
953 assert!(roots.contains_canonical(Path::new(r"\\?\UNC\server\share\ws\a.rs")));
954 assert!(!roots.contains_canonical(Path::new(r"\\?\UNC\server\other\ws\a.rs")));
955 }
956
957 #[cfg(windows)]
958 #[test]
959 fn test_verbatim_disk_equals_disk() {
960 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"C:\ws")], vec![]);
961 assert!(roots.contains_canonical(Path::new(r"\\?\C:\ws\a.rs")));
962 }
963
964 #[cfg(windows)]
965 #[test]
966 fn test_admits_uri_accepts_verbatim_prefix_root() {
967 let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\?\C:\ws")], vec![]);
968 assert!(roots.admits_uri(&Uri::from("file:///C:/ws/a.rs")));
969 }
970
971 #[test]
972 fn test_from_configured_absolute_missing_root_keeps_alias() {
973 let (_dir, base) = canonical_tempdir();
974 let missing = base.join("missing");
975 let roots = resolve_in(std::slice::from_ref(&missing), &base).unwrap();
976 assert_eq!(roots.canonical(), std::slice::from_ref(&missing));
977 assert!(roots.admits_lexically(&missing.join("a.rs")));
978 }
979
980 #[test]
981 fn test_from_configured_rejects_nonexistent_relative_path() {
982 let (_dir, base) = canonical_tempdir();
983
984 let err = resolve_in(&[PathBuf::from("missing")], &base).unwrap_err();
985
986 let Error::Config(config_err) = err else {
987 panic!("expected Config, got {err:?}");
988 };
989 let message = config_err.to_string();
990 assert!(message.contains("workspace root 'missing'"));
991 assert!(message.contains(&base.display().to_string()));
992 }
993
994 #[test]
995 fn test_from_configured_surfaces_unreadable_cwd_only_when_needed() {
996 let (_dir, base) = canonical_tempdir();
997 let broken = || Err(Error::Io(std::io::Error::other("cwd gone")));
998
999 assert_matches!(
1000 WorkspaceRoots::from_configured_with(&[], broken),
1001 Err(Error::Io(_))
1002 );
1003 assert_matches!(
1004 WorkspaceRoots::from_paths_with(&[PathBuf::from("rel")], broken),
1005 Err(Error::Io(_))
1006 );
1007 assert!(WorkspaceRoots::from_paths_with(&[base], broken).is_ok());
1008 }
1009
1010 #[cfg(unix)]
1014 #[test]
1015 fn test_from_configured_symlinked_root_keeps_alias() {
1016 let (_dir, base) = canonical_tempdir();
1017 let real = base.join("real");
1018 std::fs::create_dir(&real).unwrap();
1019 let link = base.join("link");
1020 std::os::unix::fs::symlink(&real, &link).unwrap();
1021
1022 let roots = resolve_in(std::slice::from_ref(&link), &base).unwrap();
1023
1024 assert_eq!(roots.canonical(), std::slice::from_ref(&real));
1025 assert!(roots.admits_lexically(&link.join("a.rs")));
1026 assert!(roots.admits_lexically(&real.join("a.rs")));
1027 assert!(!roots.admits_lexically(&base.join("other/a.rs")));
1028 }
1029
1030 #[test]
1031 fn test_from_configured_empty_returns_cwd() {
1032 let (_dir, base) = canonical_tempdir();
1033 let roots = resolve_in(&[], &base).unwrap();
1034 assert_eq!(roots.canonical(), [base]);
1035 }
1036
1037 #[test]
1038 fn test_from_configured_preserves_order_and_resolves_relative() {
1039 let (_dir, base) = canonical_tempdir();
1040 let absolute = base.join("absolute");
1041 std::fs::create_dir(&absolute).unwrap();
1042 std::fs::create_dir_all(base.join("relative/path")).unwrap();
1043
1044 let roots = resolve_in(&[absolute.clone(), PathBuf::from("relative/path")], &base).unwrap();
1045
1046 assert_eq!(roots.canonical(), [absolute, base.join("relative/path")]);
1047 }
1048
1049 #[test]
1050 fn test_from_configured_dot_and_parent() {
1051 let (_dir, parent) = canonical_tempdir();
1052 let nested = parent.join("nested");
1053 std::fs::create_dir(&nested).unwrap();
1054
1055 let dot = resolve_in(&[PathBuf::from(".")], &nested).unwrap();
1056 let up = resolve_in(&[PathBuf::from("..")], &nested).unwrap();
1057
1058 assert_eq!(dot.canonical(), [nested]);
1059 assert_eq!(up.canonical(), [parent]);
1060 }
1061
1062 #[test]
1063 fn test_from_configured_unicode_and_spaces() {
1064 let (_dir, base) = canonical_tempdir();
1065 let config_roots = [
1066 PathBuf::from("workspace/テスト"),
1067 PathBuf::from("workspace/тест"),
1068 PathBuf::from("another path/workspace"),
1069 ];
1070 for root in &config_roots {
1071 std::fs::create_dir_all(base.join(root)).unwrap();
1072 }
1073
1074 let roots = resolve_in(&config_roots, &base).unwrap();
1075
1076 let expected: Vec<PathBuf> = config_roots.iter().map(|r| base.join(r)).collect();
1077 assert_eq!(roots.canonical(), expected);
1078 }
1079
1080 #[test]
1082 fn test_from_configured_ignores_cwd_when_all_absolute() {
1083 let (_dir, base) = canonical_tempdir();
1084 let root = base.join("root");
1085 std::fs::create_dir(&root).unwrap();
1086
1087 let roots = WorkspaceRoots::from_paths_with(std::slice::from_ref(&root), || {
1088 panic!("cwd must not be read")
1089 })
1090 .unwrap();
1091
1092 assert_eq!(roots.canonical(), [root]);
1093 }
1094
1095 #[test]
1099 fn test_join_relative_root_strips_leading_root_and_prefix_components() {
1100 let base = Path::new("/base/dir");
1101
1102 assert_eq!(
1103 join_relative_root(base, Path::new("/workspace")),
1104 PathBuf::from("/base/dir/workspace")
1105 );
1106 assert_eq!(
1107 join_relative_root(base, Path::new("/workspace/sub")),
1108 PathBuf::from("/base/dir/workspace/sub")
1109 );
1110 assert_eq!(
1111 join_relative_root(base, Path::new("workspace")),
1112 PathBuf::from("/base/dir/workspace")
1113 );
1114 assert_eq!(
1115 join_relative_root(base, Path::new("..")),
1116 PathBuf::from("/base/dir/..")
1117 );
1118 }
1119
1120 #[cfg(windows)]
1122 #[test]
1123 fn test_from_configured_windows_root_without_prefix() {
1124 let (_dir, base) = canonical_tempdir();
1125 let nested = base.join("workspace");
1126 std::fs::create_dir(&nested).unwrap();
1127
1128 let root = PathBuf::from(r"\workspace");
1129 assert!(root.is_relative());
1130
1131 let roots = resolve_in(std::slice::from_ref(&root), &base).unwrap();
1132 assert_eq!(roots.canonical(), [nested]);
1133 }
1134
1135 #[cfg(windows)]
1138 #[test]
1139 fn test_from_configured_windows_drive_relative_root() {
1140 let (_dir, base) = canonical_tempdir();
1141 let nested = base.join("workspace");
1142 std::fs::create_dir(&nested).unwrap();
1143
1144 let Some(drive_prefix) = base.components().find_map(|c| match c {
1145 Component::Prefix(p) => Some(p.as_os_str().to_owned()),
1146 _ => None,
1147 }) else {
1148 panic!("temp dir path should have a Windows drive prefix");
1149 };
1150 let mut root = drive_prefix;
1151 root.push("workspace");
1152 let root = PathBuf::from(root);
1153 assert!(root.is_relative());
1154
1155 let roots = resolve_in(std::slice::from_ref(&root), &base).unwrap();
1156 assert_eq!(roots.canonical(), [nested]);
1157 }
1158
1159 #[cfg(unix)]
1160 #[test]
1161 fn test_process_cwd_accepts_pwd_only_when_it_names_the_cwd() {
1162 let (_dir, base) = canonical_tempdir();
1163 let link = base.join("link");
1164 std::os::unix::fs::symlink(&base, &link).unwrap();
1165 let other = tempfile::TempDir::new().unwrap();
1166 let logical = |pwd: Option<&std::ffi::OsStr>| {
1167 ProcessCwd::new(base.clone(), pwd.map(ToOwned::to_owned)).logical
1168 };
1169
1170 assert_eq!(logical(Some(link.as_os_str())), Some(link));
1171 assert_eq!(logical(Some(other.path().as_os_str())), None);
1172 assert_eq!(logical(Some(std::ffi::OsStr::new("."))), None);
1173 assert_eq!(logical(None), None);
1174 }
1175
1176 #[cfg(unix)]
1177 #[test]
1178 fn test_from_configured_relative_root_admits_logical_cwd_spelling() {
1179 let (_dir, base) = canonical_tempdir();
1180 let real = base.join("real");
1181 std::fs::create_dir_all(real.join("rel")).unwrap();
1182 let link = base.join("link");
1183 std::os::unix::fs::symlink(&real, &link).unwrap();
1184
1185 let roots = resolve_in_with_pwd(&[PathBuf::from("rel")], &real, &link).unwrap();
1186 assert!(roots.admits_lexically(&link.join("rel/f.rs")));
1187
1188 let forged = resolve_in_with_pwd(&[PathBuf::from("rel")], &real, &base).unwrap();
1189 assert!(!forged.admits_lexically(&link.join("rel/f.rs")));
1190 }
1191
1192 #[cfg(unix)]
1193 #[test]
1194 fn test_from_configured_empty_roots_admit_logical_cwd_spelling() {
1195 let (_dir, base) = canonical_tempdir();
1196 let real = base.join("real");
1197 std::fs::create_dir(&real).unwrap();
1198 let link = base.join("link");
1199 std::os::unix::fs::symlink(&real, &link).unwrap();
1200
1201 let roots = resolve_in_with_pwd(&[], &real, &link).unwrap();
1202
1203 assert_eq!(roots.canonical(), [real]);
1204 assert!(roots.admits_lexically(&link.join("f.rs")));
1205 }
1206
1207 #[cfg(unix)]
1210 #[test]
1211 fn test_from_configured_drops_alias_that_climbs_out_through_symlink() {
1212 let (_dir, base) = canonical_tempdir();
1213 let data = base.join("data/a/b");
1214 std::fs::create_dir_all(&data).unwrap();
1215 let home = base.join("home");
1216 std::fs::create_dir(&home).unwrap();
1217 let link = home.join("proj_link");
1218 std::os::unix::fs::symlink(&data, &link).unwrap();
1219
1220 let roots = resolve_in(&[link.join("..")], &base).unwrap();
1221
1222 assert_eq!(roots.canonical(), [base.join("data/a")]);
1223 assert!(roots.aliases.iter().all(|alias| !alias.starts_with(&home)));
1224 assert!(!roots.admits_lexically(&home.join("anything")));
1225 }
1226
1227 #[cfg(unix)]
1228 #[test]
1229 fn test_from_configured_keeps_alias_under_symlinked_ancestor() {
1230 let (_dir, base) = canonical_tempdir();
1231 let real = base.join("real");
1232 std::fs::create_dir_all(real.join("sub")).unwrap();
1233 let link = base.join("link");
1234 std::os::unix::fs::symlink(&real, &link).unwrap();
1235 let configured = link.join("sub");
1236
1237 let roots = resolve_in(std::slice::from_ref(&configured), &base).unwrap();
1238
1239 assert_eq!(roots.canonical(), [real.join("sub")]);
1240 assert!(roots.admits_lexically(&configured.join("a.rs")));
1241 }
1242
1243 #[cfg(not(unix))]
1244 #[test]
1245 fn test_from_configured_keeps_absolute_root_as_alias() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let raw = dir.path().to_path_buf();
1248
1249 let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&raw)).unwrap();
1250
1251 assert!(roots.admits_lexically(&raw.join("a.rs")));
1252 }
1253
1254 #[cfg(unix)]
1255 fn fake_root() -> (tempfile::TempDir, PathBuf) {
1256 let dir = tempfile::tempdir().unwrap();
1257 let base = dunce::canonicalize(dir.path()).unwrap();
1258 (dir, base)
1259 }
1260
1261 #[cfg(unix)]
1262 #[test]
1263 fn test_system_aliases_symlink_table() {
1264 let (_guard, base) = fake_root();
1265 let real = base.join("private/tmp/proj");
1266 std::fs::create_dir_all(&real).unwrap();
1267 std::os::unix::fs::symlink(base.join("private/tmp"), base.join("tmp")).unwrap();
1268 std::os::unix::fs::symlink(base.join("private/var"), base.join("var")).unwrap();
1269 std::os::unix::fs::symlink("private/tmp", base.join("rel")).unwrap();
1270
1271 let roots =
1272 WorkspaceRoots::for_test(vec![real.clone()], vec![]).with_system_aliases_in(&base);
1273
1274 assert!(roots.admits_lexically(&base.join("tmp/proj/a.rs")));
1275 assert!(roots.admits_lexically(&base.join("rel/proj/a.rs")));
1276 assert!(!roots.admits_lexically(&base.join("var/proj/a.rs")));
1277 assert!(!roots.admits_lexically(&base.join("tmp/other/a.rs")));
1278 assert!(roots.contains_canonical(&real.join("a.rs")));
1279 }
1280
1281 #[cfg(unix)]
1282 #[test]
1283 fn test_system_aliases_reject_forged_alias() {
1284 let (_guard, base) = fake_root();
1285 let real = base.join("p/b/proj");
1286 std::fs::create_dir_all(&real).unwrap();
1287 std::fs::create_dir_all(base.join("elsewhere/deep")).unwrap();
1288 std::fs::create_dir_all(base.join("elsewhere/b/proj")).unwrap();
1289 std::os::unix::fs::symlink(base.join("elsewhere/deep"), base.join("p/a")).unwrap();
1290 std::os::unix::fs::symlink("p/a/../b", base.join("x")).unwrap();
1292
1293 let roots = WorkspaceRoots::for_test(vec![real], vec![]).with_system_aliases_in(&base);
1294
1295 assert!(!roots.admits_lexically(&base.join("x/proj/a.rs")));
1296 }
1297
1298 #[cfg(unix)]
1299 #[test]
1300 fn test_system_aliases_ignore_file_dangling_and_looping_links() {
1301 let (_guard, base) = fake_root();
1302 let real = base.join("gone/proj");
1303 let file = base.join("file");
1304 std::fs::write(&file, "").unwrap();
1305 std::os::unix::fs::symlink(&file, base.join("to_file")).unwrap();
1306 std::os::unix::fs::symlink(base.join("gone"), base.join("dangling")).unwrap();
1307 std::os::unix::fs::symlink("loop_b", base.join("loop_a")).unwrap();
1308 std::os::unix::fs::symlink("loop_a", base.join("loop_b")).unwrap();
1309
1310 let roots = WorkspaceRoots::for_test(vec![real], vec![]).with_system_aliases_in(&base);
1311
1312 assert!(roots.aliases.is_empty());
1313 }
1314
1315 #[cfg(unix)]
1316 #[test]
1317 fn test_system_aliases_unreadable_dir_yields_none() {
1318 let (_guard, base) = fake_root();
1319 let roots = WorkspaceRoots::for_test(vec![base.join("r")], vec![])
1320 .with_system_aliases_in(&base.join("missing"));
1321 assert!(roots.aliases.is_empty());
1322 }
1323
1324 #[cfg(unix)]
1325 #[test]
1326 fn test_system_aliases_dotdot_and_sibling_rejected_without_stat() {
1327 let roots = WorkspaceRoots::for_test(
1328 vec![PathBuf::from("/private/tmp/proj")],
1329 vec![PathBuf::from("/tmp/proj")],
1330 );
1331 assert!(roots.admits_lexically(&PathBuf::from("/tmp/proj/a.rs")));
1332 assert!(!roots.admits_lexically(&lexically_normalize(Path::new("/tmp/proj/../other/a"))));
1333 assert!(!roots.admits_lexically(Path::new("/tmp/projx/a.rs")));
1334 }
1335
1336 #[cfg(target_os = "macos")]
1337 #[test]
1338 fn test_system_aliases_real_tmp_on_macos() {
1339 let dir = tempfile::Builder::new().tempdir_in("/tmp").unwrap();
1340 let canonical = dunce::canonicalize(dir.path()).unwrap();
1341 assert!(canonical.starts_with("/private/tmp"));
1342
1343 let roots = WorkspaceRoots::for_test(vec![canonical], vec![]).with_system_aliases();
1344
1345 assert!(roots.admits_lexically(&dir.path().join("a.rs")));
1346 assert!(!roots.admits_lexically(Path::new("/tmp/other-dir/a.rs")));
1347 }
1348
1349 #[cfg(unix)]
1350 #[tokio::test]
1351 async fn test_admits_edit_uri_refuses_a_path_under_an_unreadable_ancestor() {
1352 use std::os::unix::fs::PermissionsExt;
1353
1354 let (_dir, base) = canonical_tempdir();
1355 let probe = base.join("probe");
1356 std::fs::create_dir(&probe).unwrap();
1357 std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
1358 let root_bypasses_modes = std::fs::read_dir(&probe).is_ok();
1359 std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o755)).unwrap();
1360 if root_bypasses_modes {
1361 return;
1362 }
1363 let sealed = base.join("sealed");
1364 std::fs::create_dir(&sealed).unwrap();
1365 let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&base)).unwrap();
1366 let uri = Uri::from(format!("file://{}", sealed.join("new.rs").display()).as_str());
1367 assert!(roots.admits_edit_uri(&uri).await);
1368
1369 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o000)).unwrap();
1370 let admitted = roots.admits_edit_uri(&uri).await;
1371 std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
1372
1373 assert!(!admitted, "an unreadable ancestor must not be skipped over");
1374 }
1375
1376 #[cfg(unix)]
1377 #[tokio::test]
1378 async fn test_admits_edit_uri_refuses_a_dangling_symlink() {
1379 let (_dir, base) = canonical_tempdir();
1380 let outside = tempfile::tempdir().unwrap();
1381 let target = dunce::canonicalize(outside.path()).unwrap().join("x");
1382 std::os::unix::fs::symlink(&target, base.join("evil.rs")).unwrap();
1383 std::os::unix::fs::symlink(outside.path().join("nodir"), base.join("evil_dir")).unwrap();
1384 let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&base)).unwrap();
1385
1386 for name in ["evil.rs", "evil_dir/file.rs"] {
1387 let uri = Uri::from(format!("file://{}", base.join(name).display()).as_str());
1388 assert!(!roots.admits_edit_uri(&uri).await, "{name}");
1389 }
1390 assert!(!target.exists());
1391 }
1392}