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
21mod open_contained;
22
23/// How path components are compared during containment checks.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum CaseRule {
26    Exact,
27    FoldAscii,
28}
29
30impl CaseRule {
31    /// Rule for lexical pre-checks: platforms with case-insensitive default
32    /// filesystems may only be more permissive than the canonical check.
33    const LEXICAL: Self = if cfg!(any(windows, target_os = "macos")) {
34        Self::FoldAscii
35    } else {
36        Self::Exact
37    };
38
39    fn components_eq(self, a: Component<'_>, b: Component<'_>) -> bool {
40        match (a, b) {
41            (Component::Prefix(a), Component::Prefix(b)) => prefix_eq(a, b),
42            _ => match self {
43                Self::Exact => a == b,
44                Self::FoldAscii => a
45                    .as_os_str()
46                    .to_string_lossy()
47                    .eq_ignore_ascii_case(&b.as_os_str().to_string_lossy()),
48            },
49        }
50    }
51}
52
53/// Drive letters compare equal across `C:` and `\\?\C:`, and network shares
54/// across `\\server\share` and `\\?\UNC\server\share` (dunce keeps the
55/// verbatim form for paths beyond `MAX_PATH`).
56fn prefix_eq(a: PrefixComponent<'_>, b: PrefixComponent<'_>) -> bool {
57    let same_name = |x: &std::ffi::OsStr, y: &std::ffi::OsStr| {
58        x.to_string_lossy()
59            .eq_ignore_ascii_case(&y.to_string_lossy())
60    };
61    match (a.kind(), b.kind()) {
62        (Prefix::Disk(x) | Prefix::VerbatimDisk(x), Prefix::Disk(y) | Prefix::VerbatimDisk(y)) => {
63            x.eq_ignore_ascii_case(&y)
64        }
65        (
66            Prefix::UNC(server_a, share_a) | Prefix::VerbatimUNC(server_a, share_a),
67            Prefix::UNC(server_b, share_b) | Prefix::VerbatimUNC(server_b, share_b),
68        ) => same_name(server_a, server_b) && same_name(share_a, share_b),
69        _ => a == b,
70    }
71}
72
73/// The part of `path` below `root`, matching components with `rule`.
74///
75/// The single notion of "under a root": lookup and remainder come from the
76/// same component walk, so they cannot disagree (`C:` vs `\\?\C:`).
77fn strip_root<'p>(path: &'p Path, root: &Path, rule: CaseRule) -> Option<&'p Path> {
78    let mut components = path.components();
79    root.components()
80        .all(|r| components.next().is_some_and(|c| rule.components_eq(c, r)))
81        .then_some(components.as_path())
82}
83
84fn is_within(path: &Path, root: &Path, rule: CaseRule) -> bool {
85    strip_root(path, root, rule).is_some()
86}
87
88/// Resolves `.` and `..` without touching the filesystem; `..` never rises
89/// above the root.
90pub fn lexically_normalize(path: &Path) -> PathBuf {
91    let mut parts: Vec<Component<'_>> = Vec::new();
92    for component in path.components() {
93        match component {
94            Component::CurDir => {}
95            Component::ParentDir => match parts.last() {
96                Some(Component::Normal(_)) => {
97                    parts.pop();
98                }
99                Some(Component::RootDir | Component::Prefix(_)) => {}
100                _ => parts.push(component),
101            },
102            other => parts.push(other),
103        }
104    }
105    parts.iter().collect()
106}
107
108/// Canonicalizes one existing path; injectable so tests can simulate a slow
109/// or failing filesystem.
110pub type CanonicalizeFn = dyn Fn(&Path) -> io::Result<PathBuf> + Send + Sync;
111
112/// Why a path has no canonical form right now.
113#[derive(Debug, ThisError)]
114pub enum Unresolved {
115    /// The filesystem failed in a way that may not repeat; never cached.
116    #[error("transient filesystem error")]
117    Transient(#[source] io::Error),
118    /// Not even the root of the path could be canonicalized.
119    #[error("no ancestor of the path exists")]
120    NoExistingAncestor,
121    /// A `..` component: resolving it lexically after a missing component
122    /// could step around a symlink, so the path is refused outright.
123    #[error("path contains a `..` component")]
124    ParentComponent,
125}
126
127/// Canonicalizes `path` on disk. A dangling symlink is `NotFound` for
128/// `canonicalize` but exists as a link, so falling back to its parent would
129/// admit a path whose write lands outside the workspace; it is refused.
130fn canonicalize_on_disk(path: &Path) -> io::Result<PathBuf> {
131    dunce::canonicalize(path).map_err(|error| {
132        if error.kind() == io::ErrorKind::NotFound && std::fs::symlink_metadata(path).is_ok() {
133            io::Error::other("dangling symlink")
134        } else {
135            error
136        }
137    })
138}
139
140/// Canonicalizes the longest existing ancestor of `path` with `canonicalize`
141/// and appends the remainder, so a not-yet-created (or since-deleted) file
142/// still gets a canonical form.
143///
144/// The single canonicalization policy of the bridge. Falls back to the next
145/// ancestor only on [`io::ErrorKind::NotFound`] and
146/// [`io::ErrorKind::NotADirectory`]; every other error is
147/// [`Unresolved::Transient`]. A path with a `..` component is
148/// [`Unresolved::ParentComponent`]: a legitimate server publishes canonical
149/// paths, and the lexical join of the missing tail would otherwise bypass
150/// symlinks that `..` should have followed.
151pub fn canonicalize_existing_prefix(
152    path: &Path,
153    canonicalize: &CanonicalizeFn,
154) -> Result<PathBuf, Unresolved> {
155    if path.components().any(|c| matches!(c, Component::ParentDir)) {
156        return Err(Unresolved::ParentComponent);
157    }
158    for ancestor in path.ancestors() {
159        match canonicalize(ancestor) {
160            Ok(canonical) => {
161                return path
162                    .strip_prefix(ancestor)
163                    .map(|rest| lexically_normalize(&canonical.join(rest)))
164                    .map_err(|_| Unresolved::NoExistingAncestor);
165            }
166            Err(e)
167                if matches!(
168                    e.kind(),
169                    io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
170                ) => {}
171            Err(e) => return Err(Unresolved::Transient(e)),
172        }
173    }
174    Err(Unresolved::NoExistingAncestor)
175}
176
177/// Classifies an I/O failure on a client path: a path that cannot name a file
178/// is malformed, anything else is an I/O error.
179fn path_io_error(path: &Path, source: io::Error) -> Error {
180    match source.kind() {
181        io::ErrorKind::NotADirectory
182        | io::ErrorKind::InvalidFilename
183        | io::ErrorKind::InvalidInput => Error::MalformedPath {
184            path: path.to_path_buf(),
185            source,
186        },
187        _ => Error::FileIo {
188            path: path.to_path_buf(),
189            source,
190        },
191    }
192}
193
194/// The process working directory in its physical form plus the logical
195/// (`$PWD`) spelling when that names the same directory.
196///
197/// Injected into [`WorkspaceRoots::from_configured_with`] so tests can drive
198/// both spellings without changing the real environment.
199#[derive(Debug, Clone)]
200pub struct ProcessCwd {
201    physical: PathBuf,
202    logical: Option<PathBuf>,
203}
204
205impl ProcessCwd {
206    /// Reads the current directory and `$PWD` from the process environment.
207    pub(crate) fn current() -> Result<Self, Error> {
208        let physical = std::env::current_dir().map_err(Error::Io)?;
209        Ok(Self::new(physical, std::env::var_os("PWD")))
210    }
211
212    /// Pairs `physical` with `pwd`, which is accepted only when it is
213    /// absolute and names the same directory; a forged or stale value is
214    /// ignored.
215    pub(crate) fn new(physical: PathBuf, pwd: Option<OsString>) -> Self {
216        let logical = pwd.map(PathBuf::from).filter(|pwd| {
217            pwd.is_absolute()
218                && matches!(
219                    (dunce::canonicalize(pwd), dunce::canonicalize(&physical)),
220                    (Ok(a), Ok(b)) if a == b
221                )
222        });
223        Self { physical, logical }
224    }
225}
226
227/// Canonicalizes `probe` (resolved through the process working directory when
228/// relative) and returns the canonical path, reporting a failure against the
229/// root entry as `written` and the `base_dir` it was resolved relative to.
230///
231/// # Errors
232///
233/// Returns [`Error::Config`] when `probe` cannot be canonicalized.
234pub fn probe_root(written: &Path, base_dir: &Path, probe: &Path) -> Result<PathBuf, Error> {
235    dunce::canonicalize(probe).map_err(|source| {
236        ConfigError::UnresolvableWorkspaceRoot {
237            written: written.to_path_buf(),
238            base_dir: base_dir.to_path_buf(),
239            probe: probe.to_path_buf(),
240            source,
241        }
242        .into()
243    })
244}
245
246/// Join a relative `root` onto `base_dir`, correctly handling a root that
247/// [`Path::is_relative`] classifies `true` yet still carries a leading
248/// [`Component::Prefix`] and/or [`Component::RootDir`] -- on Windows,
249/// `is_absolute()` requires *both* a prefix and a root, so two distinct
250/// shapes are `is_relative() == true` despite being (partially) rooted:
251/// - no prefix, has root (e.g. `\workspace`) -- rooted on whichever drive is
252///   current.
253/// - has prefix, no root (e.g. `C:workspace`) -- drive-relative, resolved
254///   against that drive's own current directory.
255///
256/// Plain `base_dir.join(root)` would hit [`PathBuf::push`]'s documented
257/// special cases for both shapes, each discarding some or all of `base_dir`.
258/// Skipping any leading `Prefix`/`RootDir` components before joining
259/// sidesteps both: only the ordinary relative tail is ever appended to
260/// `base_dir`. Detected via `Component` iteration (not `#[cfg(windows)]`),
261/// so the logic is exercised by a unit test on any host -- see `#348`.
262pub fn join_relative_root(base_dir: &Path, root: &Path) -> PathBuf {
263    let mut joined = base_dir.to_path_buf();
264    joined.extend(
265        root.components()
266            .skip_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)),
267    );
268    joined
269}
270
271/// The `file:` path of `uri` when it is absolute and free of `.`/`..`
272/// components (a prefix match would otherwise let `/ws/../etc` pass for `/ws`).
273fn plain_absolute_path(uri: &Uri) -> Option<PathBuf> {
274    let path = uri_to_path(uri)?;
275    let plain = path.is_absolute()
276        && !path
277            .components()
278            .any(|c| matches!(c, Component::CurDir | Component::ParentDir));
279    plain.then_some(path)
280}
281
282/// The simplified, lexically normalized form an alias is stored (and
283/// verified) in; `None` for a relative or empty alias.
284fn stored_alias_form(alias: &Path) -> Option<PathBuf> {
285    if !alias.is_absolute() {
286        return None;
287    }
288    let form = lexically_normalize(dunce::simplified(alias));
289    (!form.is_empty()).then_some(form)
290}
291
292/// One configured root: its canonical form plus the pre-canonical spellings
293/// a client may name it by.
294struct ResolvedRoot {
295    canonical: PathBuf,
296    aliases: Vec<PathBuf>,
297}
298
299impl ResolvedRoot {
300    fn from_absolute(root: &Path) -> Self {
301        let canonical = dunce::canonicalize(root).unwrap_or_else(|source| {
302            warn!(
303                "Failed to canonicalize absolute workspace root {}: {source}, using non-canonical path",
304                root.display()
305            );
306            canonicalize_existing_prefix(root, &canonicalize_on_disk)
307                .unwrap_or_else(|_| root.to_path_buf())
308        });
309        Self {
310            canonical,
311            aliases: vec![root.to_path_buf()],
312        }
313    }
314
315    fn from_relative(root: &Path, cwd: &ProcessCwd) -> Result<Self, Error> {
316        let joined = join_relative_root(&cwd.physical, root);
317        let canonical = probe_root(root, &cwd.physical, &joined)?;
318        let mut aliases = vec![joined];
319        aliases.extend(
320            cwd.logical
321                .as_deref()
322                .map(|logical| join_relative_root(logical, root)),
323        );
324        Ok(Self { canonical, aliases })
325    }
326
327    fn from_cwd(cwd: &ProcessCwd) -> Self {
328        let canonical = dunce::canonicalize(&cwd.physical).unwrap_or_else(|e| {
329            warn!(
330                "Failed to canonicalize workspace base directory {}: {e}, using non-canonical absolute path",
331                cwd.physical.display()
332            );
333            cwd.physical.clone()
334        });
335        info!(
336            "Using workspace base directory as root: {}",
337            canonical.display()
338        );
339        let mut aliases = vec![cwd.physical.clone()];
340        aliases.extend(cwd.logical.clone());
341        Self { canonical, aliases }
342    }
343
344    /// Aliases whose stored form still resolves to this root's canonical
345    /// path. An alias that climbs out through a symlink (`link/..`) would
346    /// otherwise admit an unrelated tree and let `FileIo` errors probe it.
347    fn verified_aliases(&self) -> impl Iterator<Item = PathBuf> + '_ {
348        self.aliases.iter().filter_map(|alias| {
349            let form = stored_alias_form(alias)?;
350            if form == self.canonical
351                || canonicalize_existing_prefix(&form, &canonicalize_on_disk)
352                    .is_ok_and(|real| real == self.canonical)
353            {
354                Some(form)
355            } else {
356                debug!(
357                    "dropping workspace root alias {} that does not resolve to {}",
358                    form.display(),
359                    self.canonical.display()
360                );
361                None
362            }
363        })
364    }
365}
366
367/// A symlink found directly under the filesystem root, with its target
368/// already resolved lexically against that directory.
369#[cfg(unix)]
370#[derive(Debug, Clone, PartialEq, Eq)]
371struct RootLink {
372    link: PathBuf,
373    target: PathBuf,
374}
375
376/// Lists the symlinks directly under `links_dir` without following them, so
377/// automount or dead network links are never touched. Any I/O error yields
378/// an empty list.
379#[cfg(unix)]
380fn read_root_links(links_dir: &Path) -> Vec<RootLink> {
381    let entries = match std::fs::read_dir(links_dir) {
382        Ok(entries) => entries,
383        Err(err) => {
384            tracing::debug!(dir = %links_dir.display(), %err, "cannot list root-level links");
385            return Vec::new();
386        }
387    };
388    entries
389        .filter_map(Result::ok)
390        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_symlink()))
391        .filter_map(|entry| {
392            let link = entry.path();
393            let target = std::fs::read_link(&link).ok()?;
394            Some(RootLink {
395                target: lexically_normalize(&links_dir.join(target)),
396                link,
397            })
398        })
399        .collect()
400}
401
402/// Candidate aliases of `roots` that differ only by a root-level symlink whose
403/// target is a component prefix of the root. Unverified: callers must check
404/// `canonicalize(alias) == root`.
405#[cfg(unix)]
406fn system_symlink_aliases(roots: &[PathBuf], links: &[RootLink]) -> Vec<(PathBuf, PathBuf)> {
407    let mut candidates = Vec::new();
408    for root in roots {
409        for RootLink { link, target } in links {
410            if let Ok(rest) = root.strip_prefix(target) {
411                candidates.push((link.join(rest), root.clone()));
412            }
413        }
414    }
415    candidates
416}
417
418/// Aliases of `roots` through symlinks directly under `links_dir`, each
419/// verified to canonicalize back to its own root.
420#[cfg(unix)]
421fn verified_system_aliases(links_dir: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
422    system_symlink_aliases(roots, &read_root_links(links_dir))
423        .into_iter()
424        .filter(|(alias, root)| dunce::canonicalize(alias).is_ok_and(|real| &real == root))
425        .map(|(alias, _)| alias)
426        .collect()
427}
428
429/// A client path that passed workspace validation: lexically admitted and
430/// canonicalized under a canonical root.
431///
432/// The field is private and the only constructors are
433/// [`WorkspaceRoots::validate`] and [`WorkspaceRoots::validate_blocking`], so
434/// a function taking a `WorkspacePath` knows the path was checked once and
435/// need not canonicalize it again.
436///
437/// # Examples
438///
439/// ```
440/// use mcpls_core::bridge::{ClientPath, WorkspaceRoots};
441/// use mcpls_core::config::ConfiguredRoot;
442///
443/// let dir = std::env::temp_dir();
444/// let roots = WorkspaceRoots::from_configured(&[ConfiguredRoot::new(dir.clone())?])?;
445/// let path = roots.validate_blocking(&ClientPath::try_from(dir.clone())?)?;
446/// assert!(path.as_path().is_absolute());
447/// # Ok::<(), Box<dyn std::error::Error>>(())
448/// ```
449#[derive(Debug, Clone, PartialEq, Eq, Hash)]
450pub struct WorkspacePath(PathBuf);
451
452impl WorkspacePath {
453    /// The canonical path.
454    #[must_use]
455    pub fn as_path(&self) -> &Path {
456        &self.0
457    }
458
459    /// The canonical path, by value.
460    #[must_use]
461    pub fn into_path_buf(self) -> PathBuf {
462        self.0
463    }
464
465    /// Wraps `path` without validating it, for tests that open files directly.
466    #[cfg(test)]
467    pub(crate) const fn for_test(path: PathBuf) -> Self {
468        Self(path)
469    }
470}
471
472impl AsRef<Path> for WorkspacePath {
473    fn as_ref(&self) -> &Path {
474        &self.0
475    }
476}
477
478/// The canonical workspace roots plus the lexical aliases they may be named by.
479///
480/// A path is admitted by [`WorkspaceRoots::validate`] only if it passes the
481/// lexical pre-check against the canonical roots and their aliases (configured
482/// form, logical `$PWD`) *and* its physical, canonical form lies under a
483/// canonical root; the aliases only widen the pre-check.
484///
485/// Cheap to clone (both lists are shared). Fields are private so every
486/// instance upholds the invariants: aliases are absolute, simplified,
487/// lexically normalized and distinct from the canonical roots.
488#[derive(Debug, Clone, Default)]
489pub struct WorkspaceRoots {
490    canonical: Arc<[PathBuf]>,
491    aliases: Arc<[PathBuf]>,
492}
493
494impl WorkspaceRoots {
495    /// Builds a root set from already-canonical roots and extra lexical aliases.
496    ///
497    /// Performs no filesystem access and trusts the caller that `canonical`
498    /// is canonical. Aliases that are relative, empty after normalization,
499    /// duplicated, or equal to a canonical root are dropped.
500    fn from_parts(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
501        let mut kept: Vec<PathBuf> = Vec::new();
502        for alias in aliases {
503            let Some(alias) = stored_alias_form(&alias) else {
504                continue;
505            };
506            if canonical.contains(&alias) || kept.contains(&alias) {
507                continue;
508            }
509            kept.push(alias);
510        }
511        Self {
512            canonical: canonical.into(),
513            aliases: kept.into(),
514        }
515    }
516
517    /// The directories untrusted mode treats as the workspace: these roots
518    /// when `configured` names some, or, when it is empty and the roots are
519    /// only the working directory, [`Self::checkout_scoped`] of them.
520    pub(crate) fn untrusted_boundary(
521        &self,
522        configured: &[ConfiguredRoot],
523        home: Option<&Path>,
524    ) -> Self {
525        if configured.is_empty() {
526            self.checkout_scoped(home)
527        } else {
528            self.clone()
529        }
530    }
531
532    /// The roots that can plausibly be one checkout: those that are neither
533    /// the filesystem root nor `home`.
534    ///
535    /// For a root that was only defaulted from the working directory, which
536    /// is `/` for many GUI clients and often the home directory, treating it
537    /// as the analyzed checkout would make every binary under it a workspace
538    /// binary.
539    pub(crate) fn checkout_scoped(&self, home: Option<&Path>) -> Self {
540        let scoped = |path: &Path| path.parent().is_some() && home != Some(path);
541        Self::from_parts(
542            self.canonical
543                .iter()
544                .filter(|r| scoped(r))
545                .cloned()
546                .collect(),
547            self.aliases.iter().filter(|a| scoped(a)).cloned().collect(),
548        )
549    }
550
551    /// Builds a root set for tests that exercise purely lexical behavior;
552    /// the caller vouches for `canonical` and nothing touches the filesystem.
553    #[cfg(test)]
554    pub(crate) fn for_test(canonical: Vec<PathBuf>, aliases: Vec<PathBuf>) -> Self {
555        Self::from_parts(canonical, aliases)
556    }
557
558    /// Builds the root set for the `workspace.roots` of a configuration.
559    ///
560    /// Relative roots are resolved against the process working directory and
561    /// must exist; an empty list means the working directory itself. Absolute
562    /// roots that cannot be canonicalized (for example a directory created
563    /// after startup) fall back to their longest existing prefix. The
564    /// configured spelling, the working-directory spelling and the logical
565    /// `$PWD` spelling are kept as aliases, but only when they verifiably
566    /// resolve to the same root, so a client naming a root the way it was
567    /// configured (for example a `/var` temp dir that canonicalizes to
568    /// `/private/var`) is still admitted.
569    ///
570    /// The working directory is read only when a root needs it, so a
571    /// fully-absolute list does not fail startup over an unreadable cwd.
572    ///
573    /// # Errors
574    ///
575    /// Returns [`Error::Config`] for a relative root that cannot be
576    /// canonicalized and [`Error::Io`] when the working directory is needed
577    /// but unreadable.
578    ///
579    /// # Examples
580    ///
581    /// ```
582    /// use mcpls_core::bridge::WorkspaceRoots;
583    /// use mcpls_core::config::ConfiguredRoot;
584    ///
585    /// let roots = WorkspaceRoots::from_configured(&[ConfiguredRoot::new(std::env::temp_dir())?])?;
586    /// assert!(!roots.is_empty());
587    /// # Ok::<(), Box<dyn std::error::Error>>(())
588    /// ```
589    pub fn from_configured(roots: &[ConfiguredRoot]) -> Result<Self, Error> {
590        Self::from_configured_with(roots, ProcessCwd::current)
591    }
592
593    /// Test-only [`Self::from_configured`] over plain paths.
594    #[cfg(test)]
595    pub(crate) fn from_paths<P: AsRef<Path>>(roots: &[P]) -> Result<Self, Error> {
596        Self::from_paths_with(roots, ProcessCwd::current)
597    }
598
599    /// Test-only [`Self::from_configured_with`] over plain paths.
600    #[cfg(test)]
601    pub(crate) fn from_paths_with<P: AsRef<Path>>(
602        roots: &[P],
603        cwd: impl FnOnce() -> Result<ProcessCwd, Error>,
604    ) -> Result<Self, Error> {
605        let roots: Vec<ConfiguredRoot> = roots
606            .iter()
607            .map(|root| ConfiguredRoot::new(root.as_ref()).expect("test root"))
608            .collect();
609        Self::from_configured_with(&roots, cwd)
610    }
611
612    pub(crate) fn from_configured_with(
613        roots: &[ConfiguredRoot],
614        cwd: impl FnOnce() -> Result<ProcessCwd, Error>,
615    ) -> Result<Self, Error> {
616        let needs_cwd = roots.is_empty() || roots.iter().any(|root| root.as_path().is_relative());
617        let cwd = needs_cwd.then(cwd).transpose()?;
618
619        let resolved = match &cwd {
620            Some(cwd) if roots.is_empty() => vec![ResolvedRoot::from_cwd(cwd)],
621            _ => roots
622                .iter()
623                .map(|root| match &cwd {
624                    Some(cwd) if root.as_path().is_relative() => {
625                        ResolvedRoot::from_relative(root.as_path(), cwd)
626                    }
627                    _ => Ok(ResolvedRoot::from_absolute(root.as_path())),
628                })
629                .collect::<Result<Vec<_>, Error>>()?,
630        };
631
632        let aliases = resolved
633            .iter()
634            .flat_map(ResolvedRoot::verified_aliases)
635            .collect();
636        let canonical = resolved.into_iter().map(|root| root.canonical).collect();
637        Ok(Self::from_parts(canonical, aliases).with_system_aliases())
638    }
639
640    /// Adds aliases that differ from a canonical root only through a
641    /// root-level system symlink (`/tmp`, `/var` on macOS), discovered once
642    /// from `/`. Each alias is verified to canonicalize to its own root, so
643    /// the admitted file set never widens. No-op on non-Unix platforms.
644    #[cfg(unix)]
645    #[must_use]
646    fn with_system_aliases(self) -> Self {
647        self.with_system_aliases_in(Path::new("/"))
648    }
649
650    /// Non-Unix platforms have no root-level system symlinks to admit.
651    #[cfg(not(unix))]
652    #[must_use]
653    const fn with_system_aliases(self) -> Self {
654        self
655    }
656
657    #[cfg(unix)]
658    fn with_system_aliases_in(self, links_dir: &Path) -> Self {
659        let extra = verified_system_aliases(links_dir, &self.canonical);
660        let aliases = self.aliases.iter().cloned().chain(extra).collect();
661        Self::from_parts(self.canonical.to_vec(), aliases)
662    }
663
664    /// Validates a client path against the roots, canonicalizing it on the
665    /// blocking pool so a slow filesystem cannot stall a runtime worker.
666    ///
667    /// See [`Self::validate_blocking`] for the checks and errors.
668    ///
669    /// # Errors
670    ///
671    /// As [`Self::validate_blocking`], plus [`Error::TaskFailed`] when the
672    /// blocking task panics.
673    pub async fn validate(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
674        self.lexical_gate(path)?;
675        let roots = self.clone();
676        let path = path.clone();
677        tokio::task::spawn_blocking(move || roots.canonical_gate(&path))
678            .await
679            .map_err(|source| Error::TaskFailed {
680                task: BackgroundTask::PathValidation,
681                source,
682            })?
683    }
684
685    /// Validates a client path against the roots on the calling thread.
686    ///
687    /// The lexical check (absolute, `.`/`..` resolved, against the canonical
688    /// roots and their aliases) is only a pre-filter: it rejects an
689    /// out-of-workspace path without touching the filesystem and can only
690    /// reject, never accept. The decision that counts uses the physical
691    /// path: the original path is canonicalized (so `..` after a symlink is
692    /// resolved against the real directory) and that canonical form must lie
693    /// under a canonical root. The returned path is the canonical one.
694    ///
695    /// Touches the filesystem; async callers use [`Self::validate`].
696    ///
697    /// # Errors
698    ///
699    /// Returns [`Error::NoWorkspaceRoots`] if no root is configured -- fails
700    /// closed rather than allowing unrestricted access -- and
701    /// [`Error::PathOutsideWorkspace`] if the path is outside every root.
702    /// When the lexical check admitted the path but it cannot be
703    /// canonicalized, [`Error::FileIo`] is returned (for example it does not
704    /// exist), or [`Error::MalformedPath`] when the path itself is malformed
705    /// (it runs through a regular file, or has an invalid or over-long name).
706    pub fn validate_blocking(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
707        self.lexical_gate(path)?;
708        self.canonical_gate(path)
709    }
710
711    fn lexical_gate(&self, path: &ClientPath) -> Result<(), Error> {
712        let path = path.as_path();
713        if self.is_empty() {
714            return Err(Error::NoWorkspaceRoots(path.to_path_buf()));
715        }
716        let absolute = std::path::absolute(path).map_err(|source| path_io_error(path, source))?;
717        let normalized = lexically_normalize(dunce::simplified(&absolute));
718        self.admits_lexically(&normalized)
719            .ok_or_else(|| Error::PathOutsideWorkspace(path.to_path_buf()))
720    }
721
722    fn canonical_gate(&self, path: &ClientPath) -> Result<WorkspacePath, Error> {
723        let path = path.as_path();
724        let canonical = dunce::canonicalize(path).map_err(|source| path_io_error(path, source))?;
725        if self.contains_canonical(&canonical) {
726            Ok(WorkspacePath(canonical))
727        } else {
728            Err(Error::PathOutsideWorkspace(path.to_path_buf()))
729        }
730    }
731
732    /// The canonical roots, in configuration order.
733    #[must_use]
734    pub fn canonical(&self) -> &[PathBuf] {
735        &self.canonical
736    }
737
738    /// Whether no root is configured (path validation then fails closed).
739    #[must_use]
740    pub fn is_empty(&self) -> bool {
741        self.canonical.is_empty()
742    }
743
744    /// Whether `uri` names an absolute, `.`/`..`-free `file:` path lying under
745    /// a canonical root or one of its aliases, compared with the platform's
746    /// lenient case rule.
747    ///
748    /// A lexical **pre-filter**, never a trust boundary: it neither touches
749    /// the filesystem nor resolves symlinks, and on case-insensitive
750    /// platforms it folds case. It suits callers that either follow it with a
751    /// canonical check (the diagnostics pump, via
752    /// `PublishedPathResolver::resolve_batch`) or only annotate (the advisory
753    /// `out_of_workspace` flag). Anything that writes through a
754    /// server-supplied URI must use [`Self::admits_edit_uri`] instead.
755    /// Read-only navigation results are deliberately not filtered (standard
756    /// library and dependency locations are legitimately outside); opening
757    /// such a path still goes through the inbound [`Self::validate`] gate. Empty roots admit nothing.
758    pub(crate) fn admits_uri(&self, uri: &Uri) -> bool {
759        plain_absolute_path(uri).is_some_and(|path| self.admits_lexically(&path))
760    }
761
762    /// Whether `uri` may be written through on behalf of a server.
763    ///
764    /// Stricter than [`Self::admits_uri`]: the path must lie under a canonical
765    /// root or alias by exact component comparison (no case folding), *and*
766    /// its canonical form (longest existing prefix resolved, so a symlink
767    /// pointing out of the workspace or a retargeted alias is seen through)
768    /// must lie under a canonical root. Aliases are only a spelling aid here.
769    /// Canonicalization runs on the blocking pool.
770    pub(crate) async fn admits_edit_uri(&self, uri: &Uri) -> bool {
771        let Some(path) = plain_absolute_path(uri) else {
772            return false;
773        };
774        if !self.admits_with(&path, CaseRule::Exact) {
775            return false;
776        }
777        match tokio::task::spawn_blocking(move || {
778            canonicalize_existing_prefix(&path, &canonicalize_on_disk)
779        })
780        .await
781        {
782            Ok(canonical) => canonical.is_ok_and(|canonical| self.contains_canonical(&canonical)),
783            Err(error) => {
784                tracing::warn!(%error, uri = uri.as_ref(), "edit URI canonicalization task failed; dropping the edit");
785                false
786            }
787        }
788    }
789
790    /// Whether the absolute, lexically normalized `path` lies under a
791    /// canonical root or one of its aliases (lenient case rule). Never touches
792    /// the filesystem.
793    pub(crate) fn admits_lexically(&self, normalized: &Path) -> bool {
794        self.admits_with(normalized, CaseRule::LEXICAL)
795    }
796
797    fn admits_with(&self, path: &Path, rule: CaseRule) -> bool {
798        self.canonical
799            .iter()
800            .chain(self.aliases.iter())
801            .any(|root| is_within(path, root, rule))
802    }
803
804    /// Whether `path`, resolved through its longest existing prefix, lies under
805    /// a canonical root. A path that cannot be resolved counts as inside, so a
806    /// caller excluding workspace paths fails closed.
807    pub(crate) fn contains_resolved_prefix(&self, path: &Path) -> bool {
808        canonicalize_existing_prefix(path, &canonicalize_on_disk)
809            .map_or(true, |canonical| self.contains_canonical(&canonical))
810    }
811
812    /// The canonical root the canonical `path` lies under, if any.
813    pub(crate) fn canonical_root_of(&self, path: &Path) -> Option<&Path> {
814        self.split_under_root(path).map(|(root, _)| root)
815    }
816
817    /// The canonical root the canonical `path` lies under and the remainder
818    /// below it, from one component walk.
819    pub(crate) fn split_under_root<'r, 'p>(
820        &'r self,
821        path: &'p Path,
822    ) -> Option<(&'r Path, &'p Path)> {
823        self.canonical
824            .iter()
825            .map(PathBuf::as_path)
826            .find_map(|root| {
827                strip_root(path, root, CaseRule::Exact).map(|relative| (root, relative))
828            })
829    }
830
831    /// Whether the canonical `path` lies under a canonical root.
832    pub(crate) fn contains_canonical(&self, path: &Path) -> bool {
833        self.canonical_root_of(path).is_some()
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use std::assert_matches;
840
841    use super::*;
842
843    /// An absolute path for the host platform (`/p` on Unix, `C:\p` on Windows).
844    fn abs(path: &str) -> PathBuf {
845        if cfg!(windows) {
846            PathBuf::from(format!("C:{}", path.replace('/', "\\")))
847        } else {
848            PathBuf::from(path)
849        }
850    }
851
852    fn file_uri(path: &str) -> Uri {
853        let prefix = if cfg!(windows) {
854            "file:///C:"
855        } else {
856            "file://"
857        };
858        Uri::from(format!("{prefix}{path}").as_str())
859    }
860
861    fn resolve_in(roots: &[PathBuf], base: &Path) -> Result<WorkspaceRoots, Error> {
862        WorkspaceRoots::from_paths_with(roots, || Ok(ProcessCwd::new(base.to_path_buf(), None)))
863    }
864
865    #[cfg(unix)]
866    fn resolve_in_with_pwd(
867        roots: &[PathBuf],
868        base: &Path,
869        pwd: &Path,
870    ) -> Result<WorkspaceRoots, Error> {
871        WorkspaceRoots::from_paths_with(roots, || {
872            Ok(ProcessCwd::new(
873                base.to_path_buf(),
874                Some(pwd.as_os_str().to_owned()),
875            ))
876        })
877    }
878
879    fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
880        let dir = tempfile::tempdir().unwrap();
881        let base = dunce::canonicalize(dir.path()).unwrap();
882        (dir, base)
883    }
884
885    #[test]
886    fn test_checkout_scoped_drops_the_filesystem_root_and_home() {
887        let (home, project) = (abs("/home/me"), abs("/home/me/project"));
888        let roots = WorkspaceRoots::for_test(vec![abs("/"), home.clone(), project.clone()], vec![]);
889
890        let scoped = roots.checkout_scoped(Some(&home));
891
892        assert_eq!(scoped.canonical(), [project]);
893        assert_eq!(roots.checkout_scoped(None).canonical().len(), 2);
894    }
895
896    #[test]
897    fn test_lexically_normalize_resolves_dots() {
898        assert_eq!(
899            lexically_normalize(Path::new("/ws/./a/../b")),
900            PathBuf::from("/ws/b")
901        );
902    }
903
904    #[test]
905    fn test_lexically_normalize_never_rises_above_root() {
906        assert_eq!(
907            lexically_normalize(Path::new("/ws/../../etc")),
908            PathBuf::from("/etc")
909        );
910    }
911
912    #[test]
913    fn test_from_parts_drops_relative_duplicate_and_canonical_aliases() {
914        let roots = WorkspaceRoots::for_test(
915            vec![abs("/real")],
916            vec![
917                PathBuf::from("relative"),
918                abs("/real"),
919                abs("/alias"),
920                abs("/alias/./"),
921            ],
922        );
923        assert_eq!(roots.aliases.len(), 1);
924    }
925
926    #[test]
927    fn test_admits_lexically_alias_and_canonical() {
928        let roots = WorkspaceRoots::for_test(vec![abs("/real/ws")], vec![abs("/alias/ws")]);
929        assert!(roots.admits_lexically(&abs("/real/ws/a.rs")));
930        assert!(roots.admits_lexically(&abs("/alias/ws/a.rs")));
931        assert!(!roots.admits_lexically(&abs("/other/a.rs")));
932        assert!(!roots.admits_lexically(&abs("/real/wsx/a.rs")));
933    }
934
935    #[test]
936    fn test_empty_roots_admit_nothing() {
937        let roots = WorkspaceRoots::default();
938        assert!(roots.is_empty());
939        assert!(!roots.admits_lexically(Path::new("/a")));
940        assert!(!roots.admits_uri(&file_uri("/anywhere/at/all.rs")));
941    }
942
943    #[test]
944    fn test_admits_uri_under_root_and_alias() {
945        let roots = WorkspaceRoots::for_test(vec![abs("/real/ws")], vec![abs("/alias/ws")]);
946        assert!(roots.admits_uri(&file_uri("/real/ws/src/main.rs")));
947        assert!(roots.admits_uri(&file_uri("/alias/ws/src/main.rs")));
948    }
949
950    #[test]
951    fn test_admits_uri_rejects_outside_sibling_and_non_file() {
952        let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
953        assert!(!roots.admits_uri(&file_uri("/etc/passwd")));
954        assert!(!roots.admits_uri(&file_uri("/ws2/a.rs")));
955        assert!(!roots.admits_uri(&Uri::from("untitled:Untitled-1")));
956    }
957
958    /// `Path::starts_with` does not resolve `.`/`..`: without the explicit
959    /// check `file:///ws/../etc/passwd` would pass for `/ws`.
960    #[test]
961    fn test_admits_uri_rejects_dot_components() {
962        let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
963        assert!(!roots.admits_uri(&file_uri("/ws/../../etc/passwd")));
964    }
965
966    #[test]
967    fn test_canonicalize_existing_prefix_handles_missing_tail() {
968        let dir = tempfile::tempdir().unwrap();
969        let canonical = dunce::canonicalize(dir.path()).unwrap();
970        let result =
971            canonicalize_existing_prefix(&dir.path().join("gone/x.rs"), &canonicalize_on_disk)
972                .unwrap();
973        assert_eq!(result, canonical.join("gone/x.rs"));
974    }
975
976    #[test]
977    fn test_contains_canonical_is_case_exact() {
978        let roots = WorkspaceRoots::for_test(vec![PathBuf::from("/Real")], vec![]);
979        assert!(roots.contains_canonical(Path::new("/Real/a")));
980        assert!(!roots.contains_canonical(Path::new("/real/a")));
981    }
982
983    #[cfg(windows)]
984    #[test]
985    fn test_verbatim_unc_equals_unc() {
986        let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\server\share\ws")], vec![]);
987        assert!(roots.contains_canonical(Path::new(r"\\?\UNC\server\share\ws\a.rs")));
988        assert!(!roots.contains_canonical(Path::new(r"\\?\UNC\server\other\ws\a.rs")));
989    }
990
991    #[cfg(windows)]
992    #[test]
993    fn test_verbatim_disk_equals_disk() {
994        let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"C:\ws")], vec![]);
995        assert!(roots.contains_canonical(Path::new(r"\\?\C:\ws\a.rs")));
996    }
997
998    #[test]
999    fn test_split_under_root_returns_root_and_remainder() {
1000        let roots = WorkspaceRoots::for_test(vec![abs("/ws")], vec![]);
1001        let file = abs("/ws/sub/a.rs");
1002
1003        let (root, relative) = roots.split_under_root(&file).unwrap();
1004
1005        assert_eq!(root, abs("/ws"));
1006        assert_eq!(relative, Path::new("sub").join("a.rs"));
1007        assert_eq!(
1008            roots.split_under_root(&abs("/ws")).unwrap().1,
1009            Path::new("")
1010        );
1011        assert!(roots.split_under_root(&abs("/wsx/a.rs")).is_none());
1012        assert!(roots.split_under_root(&abs("/other/a.rs")).is_none());
1013    }
1014
1015    #[cfg(windows)]
1016    #[test]
1017    fn test_split_under_root_strips_a_verbatim_spelling_of_the_root() {
1018        let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"C:\ws")], vec![]);
1019        let long = format!(r"\\?\C:\ws\{}\a.rs", "d".repeat(300));
1020
1021        let (root, relative) = roots.split_under_root(Path::new(&long)).unwrap();
1022
1023        assert_eq!(root, Path::new(r"C:\ws"));
1024        assert_eq!(relative, Path::new(&format!(r"{}\a.rs", "d".repeat(300))));
1025    }
1026
1027    #[cfg(windows)]
1028    #[test]
1029    fn test_split_under_root_strips_a_verbatim_unc_spelling_of_the_root() {
1030        let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\server\share\ws")], vec![]);
1031
1032        let (_, relative) = roots
1033            .split_under_root(Path::new(r"\\?\UNC\server\share\ws\sub\a.rs"))
1034            .unwrap();
1035
1036        assert_eq!(relative, Path::new(r"sub\a.rs"));
1037    }
1038
1039    #[cfg(windows)]
1040    #[test]
1041    fn test_admits_uri_accepts_verbatim_prefix_root() {
1042        let roots = WorkspaceRoots::for_test(vec![PathBuf::from(r"\\?\C:\ws")], vec![]);
1043        assert!(roots.admits_uri(&Uri::from("file:///C:/ws/a.rs")));
1044    }
1045
1046    #[test]
1047    fn test_from_configured_absolute_missing_root_keeps_alias() {
1048        let (_dir, base) = canonical_tempdir();
1049        let missing = base.join("missing");
1050        let roots = resolve_in(std::slice::from_ref(&missing), &base).unwrap();
1051        assert_eq!(roots.canonical(), std::slice::from_ref(&missing));
1052        assert!(roots.admits_lexically(&missing.join("a.rs")));
1053    }
1054
1055    #[test]
1056    fn test_from_configured_rejects_nonexistent_relative_path() {
1057        let (_dir, base) = canonical_tempdir();
1058
1059        let err = resolve_in(&[PathBuf::from("missing")], &base).unwrap_err();
1060
1061        let Error::Config(config_err) = err else {
1062            panic!("expected Config, got {err:?}");
1063        };
1064        let message = config_err.to_string();
1065        assert!(message.contains("workspace root 'missing'"));
1066        assert!(message.contains(&base.display().to_string()));
1067    }
1068
1069    #[test]
1070    fn test_from_configured_surfaces_unreadable_cwd_only_when_needed() {
1071        let (_dir, base) = canonical_tempdir();
1072        let broken = || Err(Error::Io(std::io::Error::other("cwd gone")));
1073
1074        assert_matches!(
1075            WorkspaceRoots::from_configured_with(&[], broken),
1076            Err(Error::Io(_))
1077        );
1078        assert_matches!(
1079            WorkspaceRoots::from_paths_with(&[PathBuf::from("rel")], broken),
1080            Err(Error::Io(_))
1081        );
1082        assert!(WorkspaceRoots::from_paths_with(&[base], broken).is_ok());
1083    }
1084
1085    /// #234 round-3 regression: a symlinked workspace root must canonicalize
1086    /// to its real path, matching what LSP servers report in diagnostics,
1087    /// while its configured spelling stays admitted as an alias.
1088    #[cfg(unix)]
1089    #[test]
1090    fn test_from_configured_symlinked_root_keeps_alias() {
1091        let (_dir, base) = canonical_tempdir();
1092        let real = base.join("real");
1093        std::fs::create_dir(&real).unwrap();
1094        let link = base.join("link");
1095        std::os::unix::fs::symlink(&real, &link).unwrap();
1096
1097        let roots = resolve_in(std::slice::from_ref(&link), &base).unwrap();
1098
1099        assert_eq!(roots.canonical(), std::slice::from_ref(&real));
1100        assert!(roots.admits_lexically(&link.join("a.rs")));
1101        assert!(roots.admits_lexically(&real.join("a.rs")));
1102        assert!(!roots.admits_lexically(&base.join("other/a.rs")));
1103    }
1104
1105    #[test]
1106    fn test_from_configured_empty_returns_cwd() {
1107        let (_dir, base) = canonical_tempdir();
1108        let roots = resolve_in(&[], &base).unwrap();
1109        assert_eq!(roots.canonical(), [base]);
1110    }
1111
1112    #[test]
1113    fn test_from_configured_preserves_order_and_resolves_relative() {
1114        let (_dir, base) = canonical_tempdir();
1115        let absolute = base.join("absolute");
1116        std::fs::create_dir(&absolute).unwrap();
1117        std::fs::create_dir_all(base.join("relative/path")).unwrap();
1118
1119        let roots = resolve_in(&[absolute.clone(), PathBuf::from("relative/path")], &base).unwrap();
1120
1121        assert_eq!(roots.canonical(), [absolute, base.join("relative/path")]);
1122    }
1123
1124    #[test]
1125    fn test_from_configured_dot_and_parent() {
1126        let (_dir, parent) = canonical_tempdir();
1127        let nested = parent.join("nested");
1128        std::fs::create_dir(&nested).unwrap();
1129
1130        let dot = resolve_in(&[PathBuf::from(".")], &nested).unwrap();
1131        let up = resolve_in(&[PathBuf::from("..")], &nested).unwrap();
1132
1133        assert_eq!(dot.canonical(), [nested]);
1134        assert_eq!(up.canonical(), [parent]);
1135    }
1136
1137    #[test]
1138    fn test_from_configured_unicode_and_spaces() {
1139        let (_dir, base) = canonical_tempdir();
1140        let config_roots = [
1141            PathBuf::from("workspace/テスト"),
1142            PathBuf::from("workspace/тест"),
1143            PathBuf::from("another path/workspace"),
1144        ];
1145        for root in &config_roots {
1146            std::fs::create_dir_all(base.join(root)).unwrap();
1147        }
1148
1149        let roots = resolve_in(&config_roots, &base).unwrap();
1150
1151        let expected: Vec<PathBuf> = config_roots.iter().map(|r| base.join(r)).collect();
1152        assert_eq!(roots.canonical(), expected);
1153    }
1154
1155    /// #348 case 1: a fully-absolute list never reads the working directory.
1156    #[test]
1157    fn test_from_configured_ignores_cwd_when_all_absolute() {
1158        let (_dir, base) = canonical_tempdir();
1159        let root = base.join("root");
1160        std::fs::create_dir(&root).unwrap();
1161
1162        let roots = WorkspaceRoots::from_paths_with(std::slice::from_ref(&root), || {
1163            panic!("cwd must not be read")
1164        })
1165        .unwrap();
1166
1167        assert_eq!(roots.canonical(), [root]);
1168    }
1169
1170    /// #348 case 3 (S2): platform-independent test of the `Component`
1171    /// stripping; the rooted-without-prefix shape is only `is_relative()` on
1172    /// Windows, but the helper can be driven directly on any host.
1173    #[test]
1174    fn test_join_relative_root_strips_leading_root_and_prefix_components() {
1175        let base = Path::new("/base/dir");
1176
1177        assert_eq!(
1178            join_relative_root(base, Path::new("/workspace")),
1179            PathBuf::from("/base/dir/workspace")
1180        );
1181        assert_eq!(
1182            join_relative_root(base, Path::new("/workspace/sub")),
1183            PathBuf::from("/base/dir/workspace/sub")
1184        );
1185        assert_eq!(
1186            join_relative_root(base, Path::new("workspace")),
1187            PathBuf::from("/base/dir/workspace")
1188        );
1189        assert_eq!(
1190            join_relative_root(base, Path::new("..")),
1191            PathBuf::from("/base/dir/..")
1192        );
1193    }
1194
1195    /// #348 case 3: `\workspace` is relative on Windows despite being rooted.
1196    #[cfg(windows)]
1197    #[test]
1198    fn test_from_configured_windows_root_without_prefix() {
1199        let (_dir, base) = canonical_tempdir();
1200        let nested = base.join("workspace");
1201        std::fs::create_dir(&nested).unwrap();
1202
1203        let root = PathBuf::from(r"\workspace");
1204        assert!(root.is_relative());
1205
1206        let roots = resolve_in(std::slice::from_ref(&root), &base).unwrap();
1207        assert_eq!(roots.canonical(), [nested]);
1208    }
1209
1210    /// #348 M2: a drive-relative root (`C:workspace`) is joined under the
1211    /// base like every other relative root.
1212    #[cfg(windows)]
1213    #[test]
1214    fn test_from_configured_windows_drive_relative_root() {
1215        let (_dir, base) = canonical_tempdir();
1216        let nested = base.join("workspace");
1217        std::fs::create_dir(&nested).unwrap();
1218
1219        let Some(drive_prefix) = base.components().find_map(|c| match c {
1220            Component::Prefix(p) => Some(p.as_os_str().to_owned()),
1221            _ => None,
1222        }) else {
1223            panic!("temp dir path should have a Windows drive prefix");
1224        };
1225        let mut root = drive_prefix;
1226        root.push("workspace");
1227        let root = PathBuf::from(root);
1228        assert!(root.is_relative());
1229
1230        let roots = resolve_in(std::slice::from_ref(&root), &base).unwrap();
1231        assert_eq!(roots.canonical(), [nested]);
1232    }
1233
1234    #[cfg(unix)]
1235    #[test]
1236    fn test_process_cwd_accepts_pwd_only_when_it_names_the_cwd() {
1237        let (_dir, base) = canonical_tempdir();
1238        let link = base.join("link");
1239        std::os::unix::fs::symlink(&base, &link).unwrap();
1240        let other = tempfile::TempDir::new().unwrap();
1241        let logical = |pwd: Option<&std::ffi::OsStr>| {
1242            ProcessCwd::new(base.clone(), pwd.map(ToOwned::to_owned)).logical
1243        };
1244
1245        assert_eq!(logical(Some(link.as_os_str())), Some(link));
1246        assert_eq!(logical(Some(other.path().as_os_str())), None);
1247        assert_eq!(logical(Some(std::ffi::OsStr::new("."))), None);
1248        assert_eq!(logical(None), None);
1249    }
1250
1251    #[cfg(unix)]
1252    #[test]
1253    fn test_from_configured_relative_root_admits_logical_cwd_spelling() {
1254        let (_dir, base) = canonical_tempdir();
1255        let real = base.join("real");
1256        std::fs::create_dir_all(real.join("rel")).unwrap();
1257        let link = base.join("link");
1258        std::os::unix::fs::symlink(&real, &link).unwrap();
1259
1260        let roots = resolve_in_with_pwd(&[PathBuf::from("rel")], &real, &link).unwrap();
1261        assert!(roots.admits_lexically(&link.join("rel/f.rs")));
1262
1263        let forged = resolve_in_with_pwd(&[PathBuf::from("rel")], &real, &base).unwrap();
1264        assert!(!forged.admits_lexically(&link.join("rel/f.rs")));
1265    }
1266
1267    #[cfg(unix)]
1268    #[test]
1269    fn test_from_configured_empty_roots_admit_logical_cwd_spelling() {
1270        let (_dir, base) = canonical_tempdir();
1271        let real = base.join("real");
1272        std::fs::create_dir(&real).unwrap();
1273        let link = base.join("link");
1274        std::os::unix::fs::symlink(&real, &link).unwrap();
1275
1276        let roots = resolve_in_with_pwd(&[], &real, &link).unwrap();
1277
1278        assert_eq!(roots.canonical(), [real]);
1279        assert!(roots.admits_lexically(&link.join("f.rs")));
1280    }
1281
1282    /// S2: `link/..` climbs out of the root through a symlink; the alias
1283    /// would admit an unrelated tree, so it must not be recorded.
1284    #[cfg(unix)]
1285    #[test]
1286    fn test_from_configured_drops_alias_that_climbs_out_through_symlink() {
1287        let (_dir, base) = canonical_tempdir();
1288        let data = base.join("data/a/b");
1289        std::fs::create_dir_all(&data).unwrap();
1290        let home = base.join("home");
1291        std::fs::create_dir(&home).unwrap();
1292        let link = home.join("proj_link");
1293        std::os::unix::fs::symlink(&data, &link).unwrap();
1294
1295        let roots = resolve_in(&[link.join("..")], &base).unwrap();
1296
1297        assert_eq!(roots.canonical(), [base.join("data/a")]);
1298        assert!(roots.aliases.iter().all(|alias| !alias.starts_with(&home)));
1299        assert!(!roots.admits_lexically(&home.join("anything")));
1300    }
1301
1302    #[cfg(unix)]
1303    #[test]
1304    fn test_from_configured_keeps_alias_under_symlinked_ancestor() {
1305        let (_dir, base) = canonical_tempdir();
1306        let real = base.join("real");
1307        std::fs::create_dir_all(real.join("sub")).unwrap();
1308        let link = base.join("link");
1309        std::os::unix::fs::symlink(&real, &link).unwrap();
1310        let configured = link.join("sub");
1311
1312        let roots = resolve_in(std::slice::from_ref(&configured), &base).unwrap();
1313
1314        assert_eq!(roots.canonical(), [real.join("sub")]);
1315        assert!(roots.admits_lexically(&configured.join("a.rs")));
1316    }
1317
1318    #[cfg(not(unix))]
1319    #[test]
1320    fn test_from_configured_keeps_absolute_root_as_alias() {
1321        let dir = tempfile::tempdir().unwrap();
1322        let raw = dir.path().to_path_buf();
1323
1324        let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&raw)).unwrap();
1325
1326        assert!(roots.admits_lexically(&raw.join("a.rs")));
1327    }
1328
1329    #[cfg(unix)]
1330    fn fake_root() -> (tempfile::TempDir, PathBuf) {
1331        let dir = tempfile::tempdir().unwrap();
1332        let base = dunce::canonicalize(dir.path()).unwrap();
1333        (dir, base)
1334    }
1335
1336    #[cfg(unix)]
1337    #[test]
1338    fn test_system_aliases_symlink_table() {
1339        let (_guard, base) = fake_root();
1340        let real = base.join("private/tmp/proj");
1341        std::fs::create_dir_all(&real).unwrap();
1342        std::os::unix::fs::symlink(base.join("private/tmp"), base.join("tmp")).unwrap();
1343        std::os::unix::fs::symlink(base.join("private/var"), base.join("var")).unwrap();
1344        std::os::unix::fs::symlink("private/tmp", base.join("rel")).unwrap();
1345
1346        let roots =
1347            WorkspaceRoots::for_test(vec![real.clone()], vec![]).with_system_aliases_in(&base);
1348
1349        assert!(roots.admits_lexically(&base.join("tmp/proj/a.rs")));
1350        assert!(roots.admits_lexically(&base.join("rel/proj/a.rs")));
1351        assert!(!roots.admits_lexically(&base.join("var/proj/a.rs")));
1352        assert!(!roots.admits_lexically(&base.join("tmp/other/a.rs")));
1353        assert!(roots.contains_canonical(&real.join("a.rs")));
1354    }
1355
1356    #[cfg(unix)]
1357    #[test]
1358    fn test_system_aliases_reject_forged_alias() {
1359        let (_guard, base) = fake_root();
1360        let real = base.join("p/b/proj");
1361        std::fs::create_dir_all(&real).unwrap();
1362        std::fs::create_dir_all(base.join("elsewhere/deep")).unwrap();
1363        std::fs::create_dir_all(base.join("elsewhere/b/proj")).unwrap();
1364        std::os::unix::fs::symlink(base.join("elsewhere/deep"), base.join("p/a")).unwrap();
1365        // Lexically `p/a/../b` is `p/b`, physically it is `elsewhere/b`.
1366        std::os::unix::fs::symlink("p/a/../b", base.join("x")).unwrap();
1367
1368        let roots = WorkspaceRoots::for_test(vec![real], vec![]).with_system_aliases_in(&base);
1369
1370        assert!(!roots.admits_lexically(&base.join("x/proj/a.rs")));
1371    }
1372
1373    #[cfg(unix)]
1374    #[test]
1375    fn test_system_aliases_ignore_file_dangling_and_looping_links() {
1376        let (_guard, base) = fake_root();
1377        let real = base.join("gone/proj");
1378        let file = base.join("file");
1379        std::fs::write(&file, "").unwrap();
1380        std::os::unix::fs::symlink(&file, base.join("to_file")).unwrap();
1381        std::os::unix::fs::symlink(base.join("gone"), base.join("dangling")).unwrap();
1382        std::os::unix::fs::symlink("loop_b", base.join("loop_a")).unwrap();
1383        std::os::unix::fs::symlink("loop_a", base.join("loop_b")).unwrap();
1384
1385        let roots = WorkspaceRoots::for_test(vec![real], vec![]).with_system_aliases_in(&base);
1386
1387        assert!(roots.aliases.is_empty());
1388    }
1389
1390    #[cfg(unix)]
1391    #[test]
1392    fn test_system_aliases_unreadable_dir_yields_none() {
1393        let (_guard, base) = fake_root();
1394        let roots = WorkspaceRoots::for_test(vec![base.join("r")], vec![])
1395            .with_system_aliases_in(&base.join("missing"));
1396        assert!(roots.aliases.is_empty());
1397    }
1398
1399    #[cfg(unix)]
1400    #[test]
1401    fn test_system_aliases_dotdot_and_sibling_rejected_without_stat() {
1402        let roots = WorkspaceRoots::for_test(
1403            vec![PathBuf::from("/private/tmp/proj")],
1404            vec![PathBuf::from("/tmp/proj")],
1405        );
1406        assert!(roots.admits_lexically(&PathBuf::from("/tmp/proj/a.rs")));
1407        assert!(!roots.admits_lexically(&lexically_normalize(Path::new("/tmp/proj/../other/a"))));
1408        assert!(!roots.admits_lexically(Path::new("/tmp/projx/a.rs")));
1409    }
1410
1411    #[cfg(target_os = "macos")]
1412    #[test]
1413    fn test_system_aliases_real_tmp_on_macos() {
1414        let dir = tempfile::Builder::new().tempdir_in("/tmp").unwrap();
1415        let canonical = dunce::canonicalize(dir.path()).unwrap();
1416        assert!(canonical.starts_with("/private/tmp"));
1417
1418        let roots = WorkspaceRoots::for_test(vec![canonical], vec![]).with_system_aliases();
1419
1420        assert!(roots.admits_lexically(&dir.path().join("a.rs")));
1421        assert!(!roots.admits_lexically(Path::new("/tmp/other-dir/a.rs")));
1422    }
1423
1424    #[cfg(unix)]
1425    #[tokio::test]
1426    async fn test_admits_edit_uri_refuses_a_path_under_an_unreadable_ancestor() {
1427        use std::os::unix::fs::PermissionsExt;
1428
1429        let (_dir, base) = canonical_tempdir();
1430        let probe = base.join("probe");
1431        std::fs::create_dir(&probe).unwrap();
1432        std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).unwrap();
1433        let root_bypasses_modes = std::fs::read_dir(&probe).is_ok();
1434        std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o755)).unwrap();
1435        if root_bypasses_modes {
1436            return;
1437        }
1438        let sealed = base.join("sealed");
1439        std::fs::create_dir(&sealed).unwrap();
1440        let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&base)).unwrap();
1441        let uri = Uri::from(format!("file://{}", sealed.join("new.rs").display()).as_str());
1442        assert!(roots.admits_edit_uri(&uri).await);
1443
1444        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o000)).unwrap();
1445        let admitted = roots.admits_edit_uri(&uri).await;
1446        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
1447
1448        assert!(!admitted, "an unreadable ancestor must not be skipped over");
1449    }
1450
1451    #[cfg(unix)]
1452    #[tokio::test]
1453    async fn test_admits_edit_uri_refuses_a_dangling_symlink() {
1454        let (_dir, base) = canonical_tempdir();
1455        let outside = tempfile::tempdir().unwrap();
1456        let target = dunce::canonicalize(outside.path()).unwrap().join("x");
1457        std::os::unix::fs::symlink(&target, base.join("evil.rs")).unwrap();
1458        std::os::unix::fs::symlink(outside.path().join("nodir"), base.join("evil_dir")).unwrap();
1459        let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&base)).unwrap();
1460
1461        for name in ["evil.rs", "evil_dir/file.rs"] {
1462            let uri = Uri::from(format!("file://{}", base.join(name).display()).as_str());
1463            assert!(!roots.admits_edit_uri(&uri).await, "{name}");
1464        }
1465        assert!(!target.exists());
1466    }
1467}