Skip to main content

turbo_tasks_fs/
path.rs

1//! [`FileSystemPath`] and the path-resolution operations built on top of it.
2
3use std::{borrow::Cow, error::Error, fmt, path::MAIN_SEPARATOR};
4
5use anyhow::{Result, bail};
6use auto_hash_map::{AutoMap, AutoSet};
7use bincode::{Decode, Encode};
8use indexmap::IndexSet;
9use turbo_rcstr::RcStr;
10use turbo_tasks::{
11    Completion, NonLocalValue, ResolvedVc, ValueToString, ValueToStringRef, Vc, turbobail, turbofmt,
12};
13use turbo_unix_path::{
14    get_parent_path, get_relative_path_to, get_relative_request_to, join_path, normalize_path,
15};
16
17use crate::{
18    DirectoryContent, DirectoryEntry, FileContent, FileJsonContent, FileMeta, FileSystem,
19    FileSystemEntryType, LinkContent, RawDirectoryContent, RawDirectoryEntry, ReadGlobResult,
20    WriteLinkContent,
21    glob::Glob,
22    read_glob::{read_glob, track_glob},
23};
24
25#[derive(Debug, Clone, Hash)]
26#[turbo_tasks::value(shared, task_input)]
27pub struct FileSystemPath {
28    pub fs: ResolvedVc<Box<dyn FileSystem>>,
29    pub path: RcStr,
30}
31
32impl ValueToStringRef for FileSystemPath {
33    async fn to_string_ref(&self) -> Result<RcStr> {
34        turbofmt!("[{}]/{}", self.fs, self.path).await
35    }
36}
37
38#[turbo_tasks::value_impl]
39impl ValueToString for FileSystemPath {
40    #[turbo_tasks::function]
41    async fn to_string(&self) -> Result<Vc<RcStr>> {
42        Ok(Vc::cell(self.to_string_ref().await?))
43    }
44}
45
46impl FileSystemPath {
47    pub fn is_inside_ref(&self, other: &FileSystemPath) -> bool {
48        if self.fs == other.fs && self.path.starts_with(&*other.path) {
49            if other.path.is_empty() {
50                true
51            } else {
52                self.path.as_bytes().get(other.path.len()) == Some(&b'/')
53            }
54        } else {
55            false
56        }
57    }
58
59    pub fn is_inside_or_equal_ref(&self, other: &FileSystemPath) -> bool {
60        if self.fs == other.fs && self.path.starts_with(&*other.path) {
61            if other.path.is_empty() {
62                true
63            } else {
64                matches!(
65                    self.path.as_bytes().get(other.path.len()),
66                    Some(&b'/') | None
67                )
68            }
69        } else {
70            false
71        }
72    }
73
74    pub fn is_root(&self) -> bool {
75        self.path.is_empty()
76    }
77
78    pub fn is_in_node_modules(&self) -> bool {
79        self.path.starts_with("node_modules/") || self.path.contains("/node_modules/")
80    }
81
82    /// Assumes `self` is a directory. Returns a unix-style relative path of `inner` inside of
83    /// `self`, returns `None` if inner is not inside `self`.
84    ///
85    /// Note: this method always strips the leading `/` from the result.
86    pub fn get_path_to<'a>(&self, inner: &'a FileSystemPath) -> Option<&'a str> {
87        if self.fs != inner.fs {
88            return None;
89        }
90        let path = inner.path.strip_prefix(&*self.path)?;
91        if self.path.is_empty() {
92            Some(path)
93        } else if let Some(stripped) = path.strip_prefix('/') {
94            Some(stripped)
95        } else {
96            None
97        }
98    }
99
100    /// Returns a unix-style path of `other` relative to `self`, as a plain path: `dir/file.js`,
101    /// `../file.js`, or `.`. Supports traversing upwards (`../`) within the filesystem.
102    ///
103    /// Returns [`None`] when the two are on different filesystems.
104    ///
105    /// The result is not prefixed with `./`, so it is a path and not a module request. Use
106    /// [`FileSystemPath::get_relative_request_to`] to build an import specifier.
107    pub fn get_relative_path_to(&self, other: &FileSystemPath) -> Option<RcStr> {
108        if self.fs != other.fs {
109            return None;
110        }
111
112        Some(match get_relative_path_to(&self.path, &other.path) {
113            Cow::Borrowed(path) if std::ptr::eq(path, other.path.as_str()) => other.path.clone(),
114            Cow::Borrowed(path) => path.into(),
115            Cow::Owned(path) => path.into(),
116        })
117    }
118
119    /// Returns a unix-style path of `other` relative to `self`, as an explicitly relative module
120    /// request: `./dir/file.js`, `../file.js`, or `.`. Supports traversing upwards (`../`) within
121    /// the filesystem.
122    ///
123    /// Returns [`None`] when the two are on different filesystems.
124    ///
125    /// The `./` prefix is what makes the result a relative request rather than a reference to a
126    /// package of that name. Use [`FileSystemPath::get_relative_path_to`] for a plain path.
127    pub fn get_relative_request_to(&self, other: &FileSystemPath) -> Option<RcStr> {
128        if self.fs != other.fs {
129            return None;
130        }
131
132        Some(get_relative_request_to(&self.path, &other.path).into())
133    }
134
135    /// Returns the final component of the FileSystemPath, or an empty string
136    /// for the root path.
137    pub fn file_name(&self) -> &str {
138        let (_, file_name) = self.split_file_name();
139        file_name
140    }
141
142    /// Returns true if this path has the given extension
143    ///
144    /// slightly faster than `self.extension() == Some(extension)` as we can simply match a
145    /// suffix
146    pub fn has_extension(&self, extension: &str) -> bool {
147        debug_assert!(!extension.contains('/') && extension.starts_with('.'));
148        self.path.ends_with(extension)
149    }
150
151    /// Returns the extension (without a leading `.`)
152    pub fn extension(&self) -> Option<&str> {
153        let (_, extension) = self.split_extension();
154        extension
155    }
156
157    /// Splits the path into two components:
158    /// 1. The path without the extension;
159    /// 2. The extension, if any.
160    fn split_extension(&self) -> (&str, Option<&str>) {
161        if let Some((path_before_extension, extension)) = self.path.rsplit_once('.') {
162            if extension.contains('/') ||
163                // The file name begins with a `.` and has no other `.`s within.
164                path_before_extension.ends_with('/') || path_before_extension.is_empty()
165            {
166                (self.path.as_str(), None)
167            } else {
168                (path_before_extension, Some(extension))
169            }
170        } else {
171            (self.path.as_str(), None)
172        }
173    }
174
175    /// Splits the path into two components:
176    /// 1. The parent directory, if any;
177    /// 2. The file name;
178    fn split_file_name(&self) -> (Option<&str>, &str) {
179        // Since the path is normalized, we know `parent`, if any, must not be empty.
180        if let Some((parent, file_name)) = self.path.rsplit_once('/') {
181            (Some(parent), file_name)
182        } else {
183            (None, self.path.as_str())
184        }
185    }
186
187    /// Splits the path into three components:
188    /// 1. The parent directory, if any;
189    /// 2. The file stem;
190    /// 3. The extension, if any.
191    fn split_file_stem_extension(&self) -> (Option<&str>, &str, Option<&str>) {
192        let (path_before_extension, extension) = self.split_extension();
193
194        if let Some((parent, file_stem)) = path_before_extension.rsplit_once('/') {
195            (Some(parent), file_stem, extension)
196        } else {
197            (None, path_before_extension, extension)
198        }
199    }
200}
201
202#[turbo_tasks::value(transparent)]
203pub struct FileSystemPathOption(Option<FileSystemPath>);
204
205#[turbo_tasks::value_impl]
206impl FileSystemPathOption {
207    #[turbo_tasks::function]
208    pub fn none() -> Vc<Self> {
209        Vc::cell(None)
210    }
211}
212
213impl FileSystemPath {
214    /// Create a new FileSystemPath from a path within a FileSystem. The
215    /// /-separated path is expected to be already normalized (this is asserted
216    /// in dev mode).
217    pub fn new_normalized_unchecked(fs: ResolvedVc<Box<dyn FileSystem>>, path: RcStr) -> Self {
218        // On Windows, the path must be converted to a unix path before creating. But on
219        // Unix, backslashes are a valid char in file names, and the path can be
220        // provided by the user, so we allow it.
221        debug_assert!(
222            MAIN_SEPARATOR != '\\' || !path.contains('\\'),
223            "path {path} must not contain a Windows directory '\\', it must be normalized to Unix \
224             '/'",
225        );
226        debug_assert!(
227            normalize_path(&path).as_deref() == Some(&*path),
228            "path {path} must be normalized",
229        );
230        FileSystemPath { fs, path }
231    }
232
233    /// Adds a subpath to the current path. The /-separated `path` argument might contain ".." or
234    /// "." segments, but it must not leave the root of the filesystem.
235    pub fn join(&self, path: &str) -> Result<Self> {
236        if let Some(path) = join_path(&self.path, path) {
237            Ok(Self::new_normalized_unchecked(self.fs, path.into()))
238        } else {
239            bail!(
240                "FileSystemPath(\"{}\").join(\"{}\") leaves the filesystem root",
241                self.path,
242                path,
243            );
244        }
245    }
246
247    /// Adds a suffix to the filename. `path` must not contain `/`.
248    pub fn append(&self, path: &str) -> Result<Self> {
249        if path.contains('/') {
250            bail!(
251                "FileSystemPath(\"{}\").append(\"{}\") must not append '/'",
252                self.path,
253                path,
254            )
255        }
256        Ok(Self::new_normalized_unchecked(
257            self.fs,
258            format!("{}{}", self.path, path).into(),
259        ))
260    }
261
262    /// Adds a suffix to the basename of the file path. `appending` must not contain `/`. The [file
263    /// extension][FileSystemPath::extension] will stay intact.
264    pub fn append_to_stem(&self, appending: &str) -> Result<Self> {
265        if appending.contains('/') {
266            bail!(
267                "FileSystemPath({:?}).append_to_stem({:?}) must not append '/'",
268                self.path,
269                appending,
270            )
271        }
272        if let (path, Some(ext)) = self.split_extension() {
273            return Ok(Self::new_normalized_unchecked(
274                self.fs,
275                format!("{path}{appending}.{ext}").into(),
276            ));
277        }
278        Ok(Self::new_normalized_unchecked(
279            self.fs,
280            format!("{}{}", self.path, appending).into(),
281        ))
282    }
283
284    /// Similar to [FileSystemPath::join], but returns an [`Option`] that will be [`None`] when the
285    /// joined path would leave the filesystem root.
286    #[allow(clippy::needless_borrow)] // for windows build
287    pub fn try_join(&self, path: &str) -> Option<FileSystemPath> {
288        // TODO(PACK-3279): Remove this once we do not produce invalid paths at the first place.
289        #[cfg(target_os = "windows")]
290        let path = path.replace('\\', "/");
291
292        join_path(&self.path, &path)
293            .map(|p| Self::new_normalized_unchecked(self.fs, RcStr::from(p)))
294    }
295
296    /// Similar to [FileSystemPath::try_join], but returns [`None`] when the new path would leave
297    /// the current path (not just the filesystem root). This is useful for preventing access
298    /// outside of a directory.
299    pub fn try_join_inside(&self, path: &str) -> Option<FileSystemPath> {
300        if let Some(p) = join_path(&self.path, path)
301            && p.starts_with(&*self.path)
302        {
303            return Some(Self::new_normalized_unchecked(self.fs, RcStr::from(p)));
304        }
305        None
306    }
307
308    /// DETERMINISM: Result is in random order. Either sort the result or do not depend on the
309    /// order.
310    pub fn read_glob(&self, glob: Vc<Glob>) -> Vc<ReadGlobResult> {
311        read_glob(self.clone(), glob)
312    }
313
314    // Tracks all files and directories matching the glob using the filesystem watcher. Follows
315    // symlinks as though they were part of the original hierarchy. The returned [`Vc`] will be
316    // invalidated if a file or directory changes.
317    pub fn track_glob(&self, glob: Vc<Glob>, include_dot_files: bool) -> Vc<Completion> {
318        track_glob(self.clone(), glob, include_dot_files)
319    }
320
321    pub fn root(&self) -> Vc<Self> {
322        self.fs().root()
323    }
324}
325
326impl FileSystemPath {
327    pub fn fs(&self) -> Vc<Box<dyn FileSystem>> {
328        *self.fs
329    }
330
331    pub fn is_inside(&self, other: &FileSystemPath) -> bool {
332        self.is_inside_ref(other)
333    }
334
335    pub fn is_inside_or_equal(&self, other: &FileSystemPath) -> bool {
336        self.is_inside_or_equal_ref(other)
337    }
338
339    /// Creates a new [`FileSystemPath`] like `self` but with the given
340    /// extension.
341    pub fn with_extension(&self, extension: &str) -> FileSystemPath {
342        let (path_without_extension, _) = self.split_extension();
343        Self::new_normalized_unchecked(
344            self.fs,
345            // Like `Path::with_extension` and `PathBuf::set_extension`, if the extension is empty,
346            // we remove the extension altogether.
347            match extension.is_empty() {
348                true => path_without_extension.into(),
349                false => format!("{path_without_extension}.{extension}").into(),
350            },
351        )
352    }
353
354    /// Extracts the stem (non-extension) portion of self.file_name.
355    ///
356    /// The stem is:
357    ///
358    /// * [`None`], if there is no file name;
359    /// * The entire file name if there is no embedded `.`;
360    /// * The entire file name if the file name begins with `.` and has no other `.`s within;
361    /// * Otherwise, the portion of the file name before the final `.`
362    pub fn file_stem(&self) -> Option<&str> {
363        let (_, file_stem, _) = self.split_file_stem_extension();
364        if file_stem.is_empty() {
365            return None;
366        }
367        Some(file_stem)
368    }
369}
370
371impl std::fmt::Display for FileSystemPath {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        f.write_str(&self.path)
374    }
375}
376
377#[turbo_tasks::function]
378pub async fn rebase(
379    fs_path: FileSystemPath,
380    old_base: FileSystemPath,
381    new_base: FileSystemPath,
382) -> Result<Vc<FileSystemPath>> {
383    let new_path;
384    if old_base.path.is_empty() {
385        if new_base.path.is_empty() {
386            new_path = fs_path.path.clone();
387        } else {
388            new_path = [new_base.path.as_str(), "/", &fs_path.path].concat().into();
389        }
390    } else {
391        let base_path = [&old_base.path, "/"].concat();
392        if !fs_path.path.starts_with(&base_path) {
393            turbobail!(
394                "rebasing {fs_path} from {old_base} onto {new_base} doesn't work because it's not \
395                 part of the source path",
396            );
397        }
398        if new_base.path.is_empty() {
399            new_path = [&fs_path.path[base_path.len()..]].concat().into();
400        } else {
401            new_path = [new_base.path.as_str(), &fs_path.path[old_base.path.len()..]]
402                .concat()
403                .into();
404        }
405    }
406    Ok(new_base.fs.root().await?.join(&new_path)?.cell())
407}
408
409// Not turbo-tasks functions, only delegating
410impl FileSystemPath {
411    pub fn read(&self) -> Vc<FileContent> {
412        self.fs().read(self.clone())
413    }
414
415    pub fn read_link(&self) -> Vc<LinkContent> {
416        self.fs().read_link(self.clone())
417    }
418
419    pub fn is_junction_point(&self) -> Vc<bool> {
420        self.fs().is_junction_point(self.clone())
421    }
422
423    pub fn read_json(&self) -> Vc<FileJsonContent> {
424        self.fs().read(self.clone()).parse_json()
425    }
426
427    pub fn read_json5(&self) -> Vc<FileJsonContent> {
428        self.fs().read(self.clone()).parse_json5()
429    }
430
431    /// Reads content of a directory.
432    ///
433    /// DETERMINISM: Result is in random order. Either sort result or do not
434    /// depend on the order.
435    pub fn raw_read_dir(&self) -> Vc<RawDirectoryContent> {
436        self.fs().raw_read_dir(self.clone())
437    }
438
439    pub fn write(&self, content: Vc<FileContent>) -> Vc<()> {
440        self.fs().write(self.clone(), content)
441    }
442
443    /// Creates a symbolic link on *nix platforms. On Windows, directory links are created as
444    /// junction points. Links to files on Windows are attempted to be created as symbolic links.
445    ///
446    /// [Windows supports symbolic links][windows-symlink], but they [can require elevated
447    /// privileges][windows-privileges] if "developer mode" is not enabled, so we can't safely use
448    /// them. Using junction points [matches the behavior of pnpm][pnpm-windows].
449    ///
450    /// It is not recommended to create non-directory links, as this is not portable and will likely
451    /// fail on Windows.
452    ///
453    /// [windows-symlink]: https://blogs.windows.com/windowsdeveloper/2016/12/02/symlinks-windows-10/
454    /// [windows-privileges]: https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/create-symbolic-links
455    /// [pnpm-windows]: https://pnpm.io/faq#does-it-work-on-windows
456    pub fn write_link(&self, target: Vc<WriteLinkContent>) -> Vc<()> {
457        self.fs().write_link(self.clone(), target)
458    }
459
460    pub fn metadata(&self) -> Vc<FileMeta> {
461        self.fs().metadata(self.clone())
462    }
463
464    /// Returns the realpath to the file, resolving all symlinks.
465    ///
466    /// The outer [`anyhow::Error`] represents an internal error in turbo-tasks. Any other error is
467    /// represented using a structured `RealPathError`.
468    pub async fn realpath(&self) -> Result<Result<FileSystemPath, RealPathError>> {
469        Ok(self.realpath_with_links().await?.path_result.clone())
470    }
471
472    pub fn rebase(
473        fs_path: FileSystemPath,
474        old_base: FileSystemPath,
475        new_base: FileSystemPath,
476    ) -> Vc<FileSystemPath> {
477        rebase(fs_path, old_base, new_base)
478    }
479}
480
481impl FileSystemPath {
482    /// Reads content of a directory.
483    ///
484    /// DETERMINISM: Result is in random order. Either sort result or do not
485    /// depend on the order.
486    pub fn read_dir(&self) -> Vc<DirectoryContent> {
487        read_dir(self.clone())
488    }
489
490    pub fn parent(&self) -> FileSystemPath {
491        let path = &self.path;
492        if path.is_empty() {
493            return self.clone();
494        }
495        FileSystemPath::new_normalized_unchecked(self.fs, RcStr::from(get_parent_path(path)))
496    }
497
498    // It is important that get_type uses read_dir and not stat/metadata.
499    // - `get_type` is called very very often during resolving and stat would
500    // make it 1 syscall per call, whereas read_dir would make it 1 syscall per
501    // directory.
502    // - `metadata` allows you to use the "wrong" casing on
503    // case-insensitive filesystems, while read_dir gives you the "correct"
504    // casing. We want to enforce "correct" casing to avoid broken builds on
505    // Vercel deployments (case-sensitive).
506    pub fn get_type(&self) -> Vc<FileSystemEntryType> {
507        get_type(self.clone())
508    }
509
510    pub fn realpath_with_links(&self) -> Vc<RealPathWithLinksResult> {
511        realpath_with_links(self.clone())
512    }
513}
514
515#[derive(Clone, Debug)]
516#[turbo_tasks::value(shared)]
517pub struct RealPathWithLinksResult {
518    pub path_result: Result<FileSystemPath, RealPathError>,
519    pub symlinks: Box<[FileSystemPath]>,
520}
521
522/// Errors that can occur when resolving a path with symlinks.
523/// Many of these can be transient conditions that might happen when package managers are running.
524#[derive(Debug, Clone, Hash, Eq, PartialEq, NonLocalValue, Encode, Decode)]
525pub struct RealPathError {
526    original_path: FileSystemPath,
527    kind: RealPathErrorType,
528}
529
530#[derive(Debug, Clone, Hash, Eq, PartialEq, NonLocalValue, Encode, Decode)]
531pub enum RealPathErrorType {
532    TooManySymlinks {
533        symlinks: Box<[FileSystemPath]>,
534    },
535    CycleDetected {
536        symlinks: Box<[FileSystemPath]>,
537    },
538    /// A symlink or path component does not exist.
539    NotFound,
540    /// Resolution failed after finding a symlink, or the symlink was invalid to begin with.
541    Invalid {
542        reason: RcStr,
543    },
544}
545
546impl RealPathError {
547    pub fn kind(&self) -> &RealPathErrorType {
548        &self.kind
549    }
550}
551
552impl fmt::Display for RealPathError {
553    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554        match &self.kind {
555            RealPathErrorType::TooManySymlinks { symlinks } => write!(
556                f,
557                "Symlink {} leads to too many other symlinks ({} links)",
558                self.original_path,
559                symlinks.len()
560            ),
561            RealPathErrorType::CycleDetected { symlinks } => write!(
562                f,
563                "Symlink {} is in a symlink loop: {:?}",
564                self.original_path,
565                symlinks
566                    .iter()
567                    .map(|symlink| &symlink.path)
568                    .collect::<Vec<_>>()
569            ),
570            RealPathErrorType::Invalid { reason } => write!(
571                f,
572                "Symlink {} could not be resolved: {reason}",
573                self.original_path
574            ),
575            RealPathErrorType::NotFound => write!(
576                f,
577                "Path {} could not be resolved because a component does not exist",
578                self.original_path
579            ),
580        }
581    }
582}
583
584impl Error for RealPathError {}
585
586#[turbo_tasks::function]
587async fn read_dir(path: FileSystemPath) -> Result<Vc<DirectoryContent>> {
588    let fs = path.fs().to_resolved().await?;
589    match &*fs.raw_read_dir(path.clone()).await? {
590        RawDirectoryContent::NotFound => Ok(DirectoryContent::not_found()),
591        RawDirectoryContent::Entries(entries) => {
592            let mut normalized_entries = AutoMap::new();
593            let dir_path = &path.path;
594            for (name, entry) in entries {
595                // Construct the path directly instead of going through `join`.
596                // We do not need to normalize since the `name` is guaranteed to be a simple
597                // path segment.
598                let path = if dir_path.is_empty() {
599                    name.clone()
600                } else {
601                    RcStr::from(format!("{dir_path}/{name}"))
602                };
603
604                let entry_path = FileSystemPath::new_normalized_unchecked(fs, path);
605                let entry = match entry {
606                    RawDirectoryEntry::File => DirectoryEntry::File(entry_path),
607                    RawDirectoryEntry::Directory => DirectoryEntry::Directory(entry_path),
608                    RawDirectoryEntry::Symlink => DirectoryEntry::Symlink(entry_path),
609                    RawDirectoryEntry::Other => DirectoryEntry::Other(entry_path),
610                };
611                normalized_entries.insert(name.clone(), entry);
612            }
613            Ok(DirectoryContent::new(normalized_entries))
614        }
615    }
616}
617
618#[turbo_tasks::function]
619async fn get_type(path: FileSystemPath) -> Result<Vc<FileSystemEntryType>> {
620    if path.is_root() {
621        return Ok(FileSystemEntryType::Directory.cell());
622    }
623    let parent = path.parent();
624    let dir_content = parent.raw_read_dir().await?;
625    match &*dir_content {
626        RawDirectoryContent::NotFound => Ok(FileSystemEntryType::NotFound.cell()),
627        RawDirectoryContent::Entries(entries) => {
628            let (_, file_name) = path.split_file_name();
629            if let Some(entry) = entries.get(file_name) {
630                Ok(FileSystemEntryType::from(entry).cell())
631            } else {
632                Ok(FileSystemEntryType::NotFound.cell())
633            }
634        }
635    }
636}
637
638#[turbo_tasks::function]
639async fn realpath_with_links(path: FileSystemPath) -> Result<Vc<RealPathWithLinksResult>> {
640    let error_result = |original_path, kind, symlinks| {
641        RealPathWithLinksResult {
642            path_result: Err(RealPathError {
643                original_path,
644                kind,
645            }),
646            symlinks,
647        }
648        .cell()
649    };
650
651    let original_path = path.clone();
652    let mut current_path = path;
653    let mut symlinks: IndexSet<FileSystemPath> = IndexSet::new();
654    let mut visited: AutoSet<FileSystemPath> = AutoSet::new();
655    // Pick some arbitrary symlink depth limit... similar to the ELOOP logic for realpath(3).
656    // SYMLOOP_MAX is 40 for Linux: https://unix.stackexchange.com/q/721724
657    for _i in 0..40 {
658        if current_path.is_root() {
659            // fast path
660            return Ok(RealPathWithLinksResult {
661                path_result: Ok(current_path),
662                symlinks: symlinks.into_iter().collect(),
663            }
664            .cell());
665        }
666
667        if !visited.insert(current_path.clone()) {
668            let symlinks: Box<[_]> = symlinks.into_iter().collect();
669            return Ok(error_result(
670                original_path,
671                RealPathErrorType::CycleDetected {
672                    symlinks: symlinks.clone(),
673                },
674                symlinks,
675            ));
676        }
677
678        // see if a parent segment of the path is a symlink and resolve that first
679        let parent = current_path.parent();
680        let parent_result = parent.realpath_with_links().owned().await?;
681        let basename = current_path
682            .path
683            .rsplit_once('/')
684            .map_or(current_path.path.as_str(), |(_, name)| name);
685        symlinks.extend(parent_result.symlinks);
686        match parent_result.path_result {
687            Ok(path) => {
688                if path != parent {
689                    current_path = path.join(basename)?;
690                }
691            }
692            Err(parent_error) => {
693                let symlinks: Box<[_]> = symlinks.into_iter().collect();
694                return Ok(error_result(original_path, parent_error.kind, symlinks));
695            }
696        }
697
698        // use `get_type` before trying `read_link`, as there's a good chance of a cache hit on
699        // `get_type`, and `read_link` isn't the common codepath.
700        let entry_type = *current_path.get_type().await?;
701        if !matches!(entry_type, FileSystemEntryType::Symlink) {
702            if matches!(entry_type, FileSystemEntryType::NotFound) {
703                return Ok(error_result(
704                    original_path,
705                    RealPathErrorType::NotFound,
706                    symlinks.into_iter().collect(),
707                ));
708            }
709            return Ok(RealPathWithLinksResult {
710                path_result: Ok(current_path),
711                symlinks: symlinks.into_iter().collect(),
712            }
713            .cell());
714        }
715
716        let link_content = current_path.read_link().await?;
717        match &*link_content {
718            LinkContent::Link { target } => {
719                let target_path = target.file_system_path().clone();
720                symlinks.insert(current_path);
721                current_path = target_path;
722            }
723            LinkContent::NotFound => {
724                return Ok(error_result(
725                    original_path,
726                    RealPathErrorType::NotFound,
727                    symlinks.into_iter().collect(),
728                ));
729            }
730            LinkContent::Invalid { reason } => {
731                return Ok(error_result(
732                    original_path,
733                    RealPathErrorType::Invalid {
734                        reason: reason.clone(),
735                    },
736                    symlinks.into_iter().collect(),
737                ));
738            }
739        }
740    }
741
742    // Too many attempts, we bailed out!
743    // Returning the followed symlinks is still important, even if there is an error! Otherwise
744    // we may never notice if the symlink loop is fixed.
745    let symlinks: Box<[_]> = symlinks.into_iter().collect();
746    Ok(error_result(
747        original_path,
748        RealPathErrorType::TooManySymlinks {
749            symlinks: symlinks.clone(),
750        },
751        symlinks,
752    ))
753}
754
755#[cfg(test)]
756mod tests {
757    use turbo_rcstr::rcstr;
758    use turbo_tasks::Vc;
759    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
760
761    use super::*;
762    use crate::VirtualFileSystem;
763
764    /// `turbo-unix-path` covers how the relative path itself is computed, so this only pins what
765    /// this layer adds: that each method reaches for the form it names, and that neither crosses
766    /// between filesystems.
767    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
768    async fn get_relative_to() {
769        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
770            BackendOptions::default(),
771            noop_backing_storage(),
772        ));
773        tt.run_once(async move {
774            let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
775                .to_resolved()
776                .await?;
777            let dir = FileSystemPath::new_normalized_unchecked(fs, rcstr!("a/b"));
778            let file = FileSystemPath::new_normalized_unchecked(fs, rcstr!("a/b/c.js"));
779
780            assert_eq!(dir.get_relative_path_to(&file).as_deref(), Some("c.js"));
781            assert_eq!(
782                dir.get_relative_request_to(&file).as_deref(),
783                Some("./c.js")
784            );
785
786            let other_fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
787                .to_resolved()
788                .await?;
789            let elsewhere = FileSystemPath::new_normalized_unchecked(other_fs, rcstr!("a/b/c.js"));
790            assert_eq!(dir.get_relative_path_to(&elsewhere), None);
791            assert_eq!(dir.get_relative_request_to(&elsewhere), None);
792
793            anyhow::Ok(())
794        })
795        .await
796        .unwrap();
797    }
798
799    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
800    async fn with_extension() {
801        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
802            BackendOptions::default(),
803            noop_backing_storage(),
804        ));
805        tt.run_once(async move {
806            let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
807                .to_resolved()
808                .await?;
809
810            let path_txt = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar.txt"));
811
812            let path_json = path_txt.with_extension("json");
813            assert_eq!(&*path_json.path, "foo/bar.json");
814
815            let path_no_ext = path_txt.with_extension("");
816            assert_eq!(&*path_no_ext.path, "foo/bar");
817
818            let path_new_ext = path_no_ext.with_extension("json");
819            assert_eq!(&*path_new_ext.path, "foo/bar.json");
820
821            let path_no_slash_txt = FileSystemPath::new_normalized_unchecked(fs, rcstr!("bar.txt"));
822
823            let path_no_slash_json = path_no_slash_txt.with_extension("json");
824            assert_eq!(path_no_slash_json.path.as_str(), "bar.json");
825
826            let path_no_slash_no_ext = path_no_slash_txt.with_extension("");
827            assert_eq!(path_no_slash_no_ext.path.as_str(), "bar");
828
829            let path_no_slash_new_ext = path_no_slash_no_ext.with_extension("json");
830            assert_eq!(path_no_slash_new_ext.path.as_str(), "bar.json");
831
832            anyhow::Ok(())
833        })
834        .await
835        .unwrap()
836    }
837
838    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
839    async fn file_stem() {
840        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
841            BackendOptions::default(),
842            noop_backing_storage(),
843        ));
844        tt.run_once(async move {
845            let fs = Vc::upcast::<Box<dyn FileSystem>>(VirtualFileSystem::new())
846                .to_resolved()
847                .await?;
848
849            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!(""));
850            assert_eq!(path.file_stem(), None);
851
852            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar.txt"));
853            assert_eq!(path.file_stem(), Some("bar"));
854
855            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("bar.txt"));
856            assert_eq!(path.file_stem(), Some("bar"));
857
858            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/bar"));
859            assert_eq!(path.file_stem(), Some("bar"));
860
861            let path = FileSystemPath::new_normalized_unchecked(fs, rcstr!("foo/.bar"));
862            assert_eq!(path.file_stem(), Some(".bar"));
863
864            anyhow::Ok(())
865        })
866        .await
867        .unwrap()
868    }
869}