Skip to main content

mcpls_core/bridge/
workspace_roots.rs

1//! Workspace root set shared by every path-validation site.
2//!
3//! A [`WorkspaceRoots`] holds the canonical roots plus the lexical aliases a
4//! client may legitimately name them by (configured form, logical `$PWD`).
5//! Aliases are precomputed once so [`WorkspaceRoots::validate`] can reject an
6//! out-of-workspace path without touching the filesystem.
7
8use 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/// How path components are compared during containment checks.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum CaseRule {
24    Exact,
25    FoldAscii,
26}
27
28impl CaseRule {
29    /// Rule for lexical pre-checks: platforms with case-insensitive default
30    /// filesystems may only be more permissive than the canonical check.
31    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
51/// Drive letters compare equal across `C:` and `\\?\C:`, and network shares
52/// across `\\server\share` and `\\?\UNC\server\share` (dunce keeps the
53/// verbatim form for paths beyond `MAX_PATH`).
54fn 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
77/// Resolves `.` and `..` without touching the filesystem; `..` never rises
78/// above the root.
79pub 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
97/// Canonicalizes one existing path; injectable so tests can simulate a slow
98/// or failing filesystem.
99pub type CanonicalizeFn = dyn Fn(&Path) -> io::Result<PathBuf> + Send + Sync;
100
101/// Why a path has no canonical form right now.
102#[derive(Debug, ThisError)]
103pub enum Unresolved {
104    /// The filesystem failed in a way that may not repeat; never cached.
105    #[error("transient filesystem error: {0}")]
106    Transient(#[source] io::Error),
107    /// Not even the root of the path could be canonicalized.
108    #[error("no ancestor of the path exists")]
109    NoExistingAncestor,
110    /// A `..` component: resolving it lexically after a missing component
111    /// could step around a symlink, so the path is refused outright.
112    #[error("path contains a `..` component")]
113    ParentComponent,
114}
115
116/// Canonicalizes `path` on disk. A dangling symlink is `NotFound` for
117/// `canonicalize` but exists as a link, so falling back to its parent would
118/// admit a path whose write lands outside the workspace; it is refused.
119fn 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
129/// Canonicalizes the longest existing ancestor of `path` with `canonicalize`
130/// and appends the remainder, so a not-yet-created (or since-deleted) file
131/// still gets a canonical form.
132///
133/// The single canonicalization policy of the bridge. Falls back to the next
134/// ancestor only on [`io::ErrorKind::NotFound`] and
135/// [`io::ErrorKind::NotADirectory`]; every other error is
136/// [`Unresolved::Transient`]. A path with a `..` component is
137/// [`Unresolved::ParentComponent`]: a legitimate server publishes canonical
138/// paths, and the lexical join of the missing tail would otherwise bypass
139/// symlinks that `..` should have followed.
140pub 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
166/// Classifies an I/O failure on a client path: a path that cannot name a file
167/// is malformed, anything else is an I/O error.
168fn 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/// The process working directory in its physical form plus the logical
184/// (`$PWD`) spelling when that names the same directory.
185///
186/// Injected into [`WorkspaceRoots::from_configured_with`] so tests can drive
187/// both spellings without changing the real environment.
188#[derive(Debug, Clone)]
189pub struct ProcessCwd {
190    physical: PathBuf,
191    logical: Option<PathBuf>,
192}
193
194impl ProcessCwd {
195    /// Reads the current directory and `$PWD` from the process environment.
196    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    /// Pairs `physical` with `pwd`, which is accepted only when it is
202    /// absolute and names the same directory; a forged or stale value is
203    /// ignored.
204    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
216/// Canonicalizes `probe` (resolved through the process working directory when
217/// relative) and returns the canonical path, reporting a failure against the
218/// root entry as `written` and the `base_dir` it was resolved relative to.
219///
220/// # Errors
221///
222/// Returns [`Error::Config`] when `probe` cannot be canonicalized.
223pub 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
235/// Join a relative `root` onto `base_dir`, correctly handling a root that
236/// [`Path::is_relative`] classifies `true` yet still carries a leading
237/// [`Component::Prefix`] and/or [`Component::RootDir`] -- on Windows,
238/// `is_absolute()` requires *both* a prefix and a root, so two distinct
239/// shapes are `is_relative() == true` despite being (partially) rooted:
240/// - no prefix, has root (e.g. `\workspace`) -- rooted on whichever drive is
241///   current.
242/// - has prefix, no root (e.g. `C:workspace`) -- drive-relative, resolved
243///   against that drive's own current directory.
244///
245/// Plain `base_dir.join(root)` would hit [`PathBuf::push`]'s documented
246/// special cases for both shapes, each discarding some or all of `base_dir`.
247/// Skipping any leading `Prefix`/`RootDir` components before joining
248/// sidesteps both: only the ordinary relative tail is ever appended to
249/// `base_dir`. Detected via `Component` iteration (not `#[cfg(windows)]`),
250/// so the logic is exercised by a unit test on any host -- see `#348`.
251pub 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
260/// The `file:` path of `uri` when it is absolute and free of `.`/`..`
261/// components (a prefix match would otherwise let `/ws/../etc` pass for `/ws`).
262fn 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
271/// The simplified, lexically normalized form an alias is stored (and
272/// verified) in; `None` for a relative or empty alias.
273fn 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
281/// One configured root: its canonical form plus the pre-canonical spellings
282/// a client may name it by.
283struct 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    /// Aliases whose stored form still resolves to this root's canonical
334    /// path. An alias that climbs out through a symlink (`link/..`) would
335    /// otherwise admit an unrelated tree and let `FileIo` errors probe it.
336    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/// A symlink found directly under the filesystem root, with its target
357/// already resolved lexically against that directory.
358#[cfg(unix)]
359#[derive(Debug, Clone, PartialEq, Eq)]
360struct RootLink {
361    link: PathBuf,
362    target: PathBuf,
363}
364
365/// Lists the symlinks directly under `links_dir` without following them, so
366/// automount or dead network links are never touched. Any I/O error yields
367/// an empty list.
368#[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/// Candidate aliases of `roots` that differ only by a root-level symlink whose
392/// target is a component prefix of the root. Unverified: callers must check
393/// `canonicalize(alias) == root`.
394#[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/// Aliases of `roots` through symlinks directly under `links_dir`, each
408/// verified to canonicalize back to its own root.
409#[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/// A client path that passed workspace validation: lexically admitted and
419/// canonicalized under a canonical root.
420///
421/// The field is private and the only constructors are
422/// [`WorkspaceRoots::validate`] and [`WorkspaceRoots::validate_blocking`], so
423/// a function taking a `WorkspacePath` knows the path was checked once and
424/// need not canonicalize it again.
425///
426/// # Examples
427///
428/// ```
429/// use mcpls_core::bridge::{ClientPath, WorkspaceRoots};
430/// use mcpls_core::config::ConfiguredRoot;
431///
432/// let dir = std::env::temp_dir();
433/// let roots = WorkspaceRoots::from_configured(&[ConfiguredRoot::new(dir.clone())?])?;
434/// let path = roots.validate_blocking(&ClientPath::try_from(dir.clone())?)?;
435/// assert!(path.as_path().is_absolute());
436/// # Ok::<(), Box<dyn std::error::Error>>(())
437/// ```
438#[derive(Debug, Clone, PartialEq, Eq, Hash)]
439pub struct WorkspacePath(PathBuf);
440
441impl WorkspacePath {
442    /// The canonical path.
443    #[must_use]
444    pub fn as_path(&self) -> &Path {
445        &self.0
446    }
447
448    /// The canonical path, by value.
449    #[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/// The canonical workspace roots plus the lexical aliases they may be named by.
462///
463/// A path is admitted by [`WorkspaceRoots::validate`] only if it passes the
464/// lexical pre-check against the canonical roots and their aliases (configured
465/// form, logical `$PWD`) *and* its physical, canonical form lies under a
466/// canonical root; the aliases only widen the pre-check.
467///
468/// Cheap to clone (both lists are shared). Fields are private so every
469/// instance upholds the invariants: aliases are absolute, simplified,
470/// lexically normalized and distinct from the canonical roots.
471#[derive(Debug, Clone, Default)]
472pub struct WorkspaceRoots {
473    canonical: Arc<[PathBuf]>,
474    aliases: Arc<[PathBuf]>,
475}
476
477impl WorkspaceRoots {
478    /// Builds a root set from already-canonical roots and extra lexical aliases.
479    ///
480    /// Performs no filesystem access and trusts the caller that `canonical`
481    /// is canonical. Aliases that are relative, empty after normalization,
482    /// duplicated, or equal to a canonical root are dropped.
483    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    /// The directories untrusted mode treats as the workspace: these roots
501    /// when `configured` names some, or, when it is empty and the roots are
502    /// only the working directory, [`Self::checkout_scoped`] of them.
503    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    /// The roots that can plausibly be one checkout: those that are neither
516    /// the filesystem root nor `home`.
517    ///
518    /// For a root that was only defaulted from the working directory, which
519    /// is `/` for many GUI clients and often the home directory, treating it
520    /// as the analyzed checkout would make every binary under it a workspace
521    /// binary.
522    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    /// Builds a root set for tests that exercise purely lexical behavior;
535    /// the caller vouches for `canonical` and nothing touches the filesystem.
536    #[cfg(test)]
537    pub(crate) fn for_test(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
538        Self::from_parts(canonical, aliases)
539    }
540
541    /// Builds the root set for the `workspace.roots` of a configuration.
542    ///
543    /// Relative roots are resolved against the process working directory and
544    /// must exist; an empty list means the working directory itself. Absolute
545    /// roots that cannot be canonicalized (for example a directory created
546    /// after startup) fall back to their longest existing prefix. The
547    /// configured spelling, the working-directory spelling and the logical
548    /// `$PWD` spelling are kept as aliases, but only when they verifiably
549    /// resolve to the same root, so a client naming a root the way it was
550    /// configured (for example a `/var` temp dir that canonicalizes to
551    /// `/private/var`) is still admitted.
552    ///
553    /// The working directory is read only when a root needs it, so a
554    /// fully-absolute list does not fail startup over an unreadable cwd.
555    ///
556    /// # Errors
557    ///
558    /// Returns [`Error::Config`] for a relative root that cannot be
559    /// canonicalized and [`Error::Io`] when the working directory is needed
560    /// but unreadable.
561    ///
562    /// # Examples
563    ///
564    /// ```
565    /// use mcpls_core::bridge::WorkspaceRoots;
566    /// use mcpls_core::config::ConfiguredRoot;
567    ///
568    /// let roots = WorkspaceRoots::from_configured(&[ConfiguredRoot::new(std::env::temp_dir())?])?;
569    /// assert!(!roots.is_empty());
570    /// # Ok::<(), Box<dyn std::error::Error>>(())
571    /// ```
572    pub fn from_configured(roots: &[ConfiguredRoot]) -> Result<Self, Error> {
573        Self::from_configured_with(roots, ProcessCwd::current)
574    }
575
576    /// Test-only [`Self::from_configured`] over plain paths.
577    #[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    /// Test-only [`Self::from_configured_with`] over plain paths.
583    #[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    /// Adds aliases that differ from a canonical root only through a
624    /// root-level system symlink (`/tmp`, `/var` on macOS), discovered once
625    /// from `/`. Each alias is verified to canonicalize to its own root, so
626    /// the admitted file set never widens. No-op on non-Unix platforms.
627    #[cfg(unix)]
628    #[must_use]
629    fn with_system_aliases(self) -> Self {
630        self.with_system_aliases_in(Path::new("/"))
631    }
632
633    /// Non-Unix platforms have no root-level system symlinks to admit.
634    #[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    /// Validates a client path against the roots, canonicalizing it on the
648    /// blocking pool so a slow filesystem cannot stall a runtime worker.
649    ///
650    /// See [`Self::validate_blocking`] for the checks and errors.
651    ///
652    /// # Errors
653    ///
654    /// As [`Self::validate_blocking`], plus [`Error::TaskFailed`] when the
655    /// blocking task panics.
656    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    /// Validates a client path against the roots on the calling thread.
669    ///
670    /// The lexical check (absolute, `.`/`..` resolved, against the canonical
671    /// roots and their aliases) is only a pre-filter: it rejects an
672    /// out-of-workspace path without touching the filesystem and can only
673    /// reject, never accept. The decision that counts uses the physical
674    /// path: the original path is canonicalized (so `..` after a symlink is
675    /// resolved against the real directory) and that canonical form must lie
676    /// under a canonical root. The returned path is the canonical one.
677    ///
678    /// Touches the filesystem; async callers use [`Self::validate`].
679    ///
680    /// # Errors
681    ///
682    /// Returns [`Error::NoWorkspaceRoots`] if no root is configured -- fails
683    /// closed rather than allowing unrestricted access -- and
684    /// [`Error::PathOutsideWorkspace`] if the path is outside every root.
685    /// When the lexical check admitted the path but it cannot be
686    /// canonicalized, [`Error::FileIo`] is returned (for example it does not
687    /// exist), or [`Error::MalformedPath`] when the path itself is malformed
688    /// (it runs through a regular file, or has an invalid or over-long name).
689    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    /// The canonical roots, in configuration order.
716    #[must_use]
717    pub fn canonical(&self) -> &[PathBuf] {
718        &self.canonical
719    }
720
721    /// Whether no root is configured (path validation then fails closed).
722    #[must_use]
723    pub fn is_empty(&self) -> bool {
724        self.canonical.is_empty()
725    }
726
727    /// Whether `uri` names an absolute, `.`/`..`-free `file:` path lying under
728    /// a canonical root or one of its aliases, compared with the platform's
729    /// lenient case rule.
730    ///
731    /// A lexical **pre-filter**, never a trust boundary: it neither touches
732    /// the filesystem nor resolves symlinks, and on case-insensitive
733    /// platforms it folds case. It suits callers that either follow it with a
734    /// canonical check (the diagnostics pump, via
735    /// `PublishedPathResolver::resolve_batch`) or only annotate (the advisory
736    /// `out_of_workspace` flag). Anything that writes through a
737    /// server-supplied URI must use [`Self::admits_edit_uri`] instead.
738    /// Read-only navigation results are deliberately not filtered (standard
739    /// library and dependency locations are legitimately outside); opening
740    /// such a path still goes through the inbound [`Self::validate`] gate. Empty roots admit nothing.
741    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    /// Whether `uri` may be written through on behalf of a server.
746    ///
747    /// Stricter than [`Self::admits_uri`]: the path must lie under a canonical
748    /// root or alias by exact component comparison (no case folding), *and*
749    /// its canonical form (longest existing prefix resolved, so a symlink
750    /// pointing out of the workspace or a retargeted alias is seen through)
751    /// must lie under a canonical root. Aliases are only a spelling aid here.
752    /// Canonicalization runs on the blocking pool.
753    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    /// Whether the absolute, lexically normalized `path` lies under a
774    /// canonical root or one of its aliases (lenient case rule). Never touches
775    /// the filesystem.
776    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    /// Whether `path`, resolved through its longest existing prefix, lies under
788    /// a canonical root. A path that cannot be resolved counts as inside, so a
789    /// caller excluding workspace paths fails closed.
790    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    /// Whether the canonical `path` lies under a canonical root.
796    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    /// An absolute path for the host platform (`/p` on Unix, `C:\p` on Windows).
810    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    /// `Path::starts_with` does not resolve `.`/`..`: without the explicit
925    /// check `file:///ws/../etc/passwd` would pass for `/ws`.
926    #[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    /// #234 round-3 regression: a symlinked workspace root must canonicalize
1011    /// to its real path, matching what LSP servers report in diagnostics,
1012    /// while its configured spelling stays admitted as an alias.
1013    #[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    /// #348 case 1: a fully-absolute list never reads the working directory.
1081    #[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    /// #348 case 3 (S2): platform-independent test of the `Component`
1096    /// stripping; the rooted-without-prefix shape is only `is_relative()` on
1097    /// Windows, but the helper can be driven directly on any host.
1098    #[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    /// #348 case 3: `\workspace` is relative on Windows despite being rooted.
1121    #[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    /// #348 M2: a drive-relative root (`C:workspace`) is joined under the
1136    /// base like every other relative root.
1137    #[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    /// S2: `link/..` climbs out of the root through a symlink; the alias
1208    /// would admit an unrelated tree, so it must not be recorded.
1209    #[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        // Lexically `p/a/../b` is `p/b`, physically it is `elsewhere/b`.
1291        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}