Skip to main content

turbo_tasks_fs/
disk.rs

1//! The on-disk [`DiskFileSystem`] implementation and its supporting helpers.
2
3use std::{
4    env,
5    ffi::OsString,
6    fmt::{self, Debug, Formatter},
7    future::Future,
8    io::{self, ErrorKind, Write as _},
9    mem::take,
10    ops::ControlFlow,
11    path::{Component, MAIN_SEPARATOR, Path, PathBuf, Prefix},
12    sync::{Arc, LazyLock, Weak},
13};
14
15use anyhow::{Context, Result, anyhow};
16use async_trait::async_trait;
17use bincode::{Decode, Encode};
18#[cfg(windows)]
19use omnipath::WinPathExt;
20use rustc_hash::FxHashSet;
21use tokio::{
22    runtime::Handle,
23    sync::{RwLock, RwLockReadGuard},
24};
25use tracing::Instrument;
26use turbo_rcstr::{RcStr, rcstr};
27use turbo_tasks::{
28    CapturedEffect, Effect, EffectExt, EffectStateStorage, InvalidationReason, Invalidator,
29    NonLocalValue, OperationVc, ReadRef, ResolvedVc, TurboTasksApi, ValueToString, Vc,
30    debug::ValueDebugFormat, turbo_tasks_weak, turbobail,
31};
32use turbo_tasks_hash::{hash_xxh3_hash64, hash_xxh3_hash128};
33use turbo_unix_path::{normalize_path, sys_to_unix, unix_to_sys};
34
35#[cfg(windows)]
36use crate::windows::{is_link_junction_point, to_verbatim_with_case_folded_disk};
37use crate::{
38    AnyhowWrapper, DiskFileSystemMap, File, FileComparison, FileContent, FileMeta, FileSystem,
39    FileSystemPath, FileSystemPathOption, LinkContent, LinkTarget, PersistedFileContent,
40    RawDirectoryContent, RawDirectoryEntry, WriteLinkContent, WriteLinkTargetType,
41    canonicalized_path_cache::CanonicalizedPathWalkCache,
42    invalidation::Write,
43    invalidator_map::InvalidatorMap,
44    mutex_map::MutexMap,
45    path_map::OrderedPathMapExt,
46    retry::{can_retry, retry_blocking, retry_blocking_custom},
47    watcher::{DiskWatcher, DiskWatcherConfig},
48};
49
50/// Validate the path, returning the valid path, a modified-but-now-valid path, or bailing with an
51/// error.
52///
53/// The behaviour of the file system changes depending on the OS, and indeed sometimes the FS
54/// implementation of the OS itself.
55///
56/// - On Windows the limit for normal file paths is 260 characters, a holdover from the DOS days,
57///   but we use 'verbatim' or 'extended' paths for supported path operations which can be up to
58///   32767 characters long.
59/// - On macOS, the limit is traditionally 255 characters for the file name and a second limit of
60///   1024 for the entire path (verified by running `getconf PATH_MAX /`).
61/// - On Linux, the limit differs between kernel (and by extension, distro) and filesystem. On most
62///   common file systems (e.g. ext4, btrfs, and xfs), individual file names can be up to 255 bytes
63///   with no hard limit on total path length. [Some legacy POSIX APIs are restricted to the
64///   `PATH_MAX` value of 4096 bytes in `limits.h`, but most applications support longer
65///   paths][PATH_MAX].
66///
67/// For more details, refer to <https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits>.
68///
69/// Realistically, the output path lengths will be the same across all platforms, so we need to set
70/// a conservative limit and be particular about when we decide to bump it. Here we have opted for
71/// 255 characters, because it is the shortest of the three options.
72///
73/// [PATH_MAX]: https://eklitzke.org/path-max-is-tricky
74pub fn validate_path_length(path: &Path) -> io::Result<()> {
75    fn error(name_or_path: &str, len: usize, limit: usize) -> io::Error {
76        io::Error::new(
77            io::ErrorKind::InvalidFilename,
78            format!("file {name_or_path} is too long ({len}) exceeds filesystem limit of {limit}"),
79        )
80    }
81    if cfg!(windows) {
82        // We always use verbatim paths internally in turbo-tasks-fs
83        debug_assert!(
84            matches!(
85                path.components().next(),
86                Some(std::path::Component::Prefix(prefix)) if prefix.kind().is_verbatim()
87            ),
88            "expected a verbatim path, got {path:?}",
89        );
90
91        // We subtract a 100-character safety margin from the real value because:
92        // > The maximum path of 32,767 characters is approximate, because the "\\?\" prefix may
93        // > be expanded to a longer string by the system at run time, and this expansion
94        // > applies to the total length.
95        const MAX_VERBATIM_PATH_LENGTH_WINDOWS: usize = 32_767 - 100;
96        let len = path.as_os_str().len();
97        if len > MAX_VERBATIM_PATH_LENGTH_WINDOWS {
98            return Err(error("path", len, MAX_VERBATIM_PATH_LENGTH_WINDOWS));
99        }
100    }
101
102    if cfg!(unix) {
103        /// here we are only going to check if the total length exceeds, or the last segment
104        /// exceeds. This heuristic is primarily to avoid long file names, and it makes the
105        /// operation much cheaper.
106        const MAX_FILE_NAME_LENGTH_UNIX: usize = 255;
107
108        // just check the last segment (file name), assume parent directories must've already
109        // been constructed successfully
110        let name_len = path
111            .file_name()
112            .map(|n| n.as_encoded_bytes().len())
113            .unwrap_or(0);
114        if name_len > MAX_FILE_NAME_LENGTH_UNIX {
115            return Err(error("name", name_len, MAX_FILE_NAME_LENGTH_UNIX));
116        }
117
118        // Note: Most popular filesystems on Linux (ext4, btrfs) have no hard limit on total
119        // path length. The `PATH_MAX` constant is not enforced on modern systems in glibc or in
120        // the kernel. See: https://eklitzke.org/path-max-is-tricky
121        if cfg!(target_os = "macos") {
122            #[cfg(unix)]
123            {
124                use std::os::unix::ffi::OsStrExt;
125                // macOS reports a limit of 1024, but I (@arlyon) have had issues with paths
126                // above 1016 so we subtract a bit to be safe. on most linux distros this is
127                // likely a lot larger than 1024, but macOS is *special*
128                const MAX_PATH_LENGTH: usize = 1024 - 8;
129                let path_len = path.as_os_str().as_bytes().len();
130                if path_len > MAX_PATH_LENGTH {
131                    return Err(error("path", path_len, MAX_PATH_LENGTH));
132                }
133            }
134        }
135    }
136
137    // unknown platform: skip this check
138    Ok(())
139}
140
141/// A thin wrapper around [`std::fs::canonicalize`] that returns the result as an [`RcStr`].
142///
143/// The returned path is in the format [`DiskFileSystem::new`] expects: an absolute,
144/// symlink-resolved path (a verbatim/extended `\\?\`-prefixed path on Windows). `path` must already
145/// exist on disk. Returns an error if it cannot be canonicalized or is not valid unicode.
146pub fn canonicalize_to_rcstr(path: &Path) -> io::Result<RcStr> {
147    fs_err::canonicalize(path)?
148        .into_string()
149        .map(RcStr::from)
150        .map_err(|p| {
151            io::Error::new(
152                ErrorKind::InvalidFilename,
153                anyhow!("canonicalized path {p:?} is not valid unicode"),
154            )
155        })
156}
157
158trait ConcurrencyLimitedExt {
159    type Output;
160    async fn concurrency_limited(self, semaphore: &tokio::sync::Semaphore) -> Self::Output;
161}
162
163impl<F, R> ConcurrencyLimitedExt for F
164where
165    F: Future<Output = R>,
166{
167    type Output = R;
168    async fn concurrency_limited(self, semaphore: &tokio::sync::Semaphore) -> Self::Output {
169        let _permit = semaphore.acquire().await;
170        self.await
171    }
172}
173
174fn number_env_var(name: &'static str) -> Option<usize> {
175    env::var(name)
176        .ok()
177        .filter(|val| !val.is_empty())
178        .map(|val| match val.parse() {
179            Ok(n) => n,
180            Err(err) => panic!("{name} must be a valid integer: {err}"),
181        })
182        .filter(|val| *val != 0)
183}
184
185fn create_read_semaphore() -> tokio::sync::Semaphore {
186    // the semaphore isn't serialized, and we assume the environment variable doesn't change during
187    // runtime, so it's okay to access it in this untracked way.
188    static TURBO_ENGINE_READ_CONCURRENCY: LazyLock<usize> =
189        LazyLock::new(|| number_env_var("TURBO_ENGINE_READ_CONCURRENCY").unwrap_or(64));
190    tokio::sync::Semaphore::new(*TURBO_ENGINE_READ_CONCURRENCY)
191}
192
193fn create_write_semaphore() -> tokio::sync::Semaphore {
194    // the semaphore isn't serialized, and we assume the environment variable doesn't change during
195    // runtime, so it's okay to access it in this untracked way.
196    static TURBO_ENGINE_WRITE_CONCURRENCY: LazyLock<usize> = LazyLock::new(|| {
197        number_env_var("TURBO_ENGINE_WRITE_CONCURRENCY").unwrap_or(
198            // We write a lot of smallish files where high concurrency will cause metadata
199            // thrashing. So 4 threads is a safe cross platform suitable value.
200            4,
201        )
202    });
203    tokio::sync::Semaphore::new(*TURBO_ENGINE_WRITE_CONCURRENCY)
204}
205
206#[derive(ValueDebugFormat, NonLocalValue, Encode, Decode)]
207pub(crate) struct DiskFileSystemInner {
208    pub name: RcStr,
209    /// A system path in utf-8 representation. This simplifies serialization/deserialization.
210    ///
211    /// On Windows, this should use the verbatim/extended (`\\?\`-prefixed) path format. That
212    /// format supports paths exceeding the 260 character path limit.
213    ///
214    /// In the future, we should consider using `Path`/`PathBuf` here. Paths inside of the
215    /// `DiskFileSystem` must be valid unicode, but the root path doesn't need to be.
216    root: RcStr,
217    /// Results of checking prefixes for this root when the filesystem map does not contain it.
218    #[turbo_tasks(debug_ignore, unsafe_ignore)]
219    #[bincode(skip)]
220    root_prefixes: CanonicalizedPathWalkCache,
221    #[turbo_tasks(debug_ignore, unsafe_ignore)]
222    #[bincode(skip)]
223    mutex_map: MutexMap<Arc<PathBuf>>,
224    #[turbo_tasks(debug_ignore, unsafe_ignore)]
225    #[bincode(skip)]
226    pub(crate) invalidator_map: InvalidatorMap,
227    #[turbo_tasks(debug_ignore, unsafe_ignore)]
228    #[bincode(skip)]
229    pub(crate) dir_invalidator_map: InvalidatorMap,
230    /// Lock that makes invalidation atomic. It will keep a write lock during
231    /// watcher invalidation and a read lock during other operations.
232    #[turbo_tasks(debug_ignore, unsafe_ignore)]
233    #[bincode(skip)]
234    pub(crate) invalidation_lock: RwLock<()>,
235    /// Semaphore to limit the maximum number of concurrent file operations.
236    #[turbo_tasks(debug_ignore, unsafe_ignore)]
237    #[bincode(skip, default = "create_read_semaphore")]
238    read_semaphore: tokio::sync::Semaphore,
239    /// Semaphore to limit the maximum number of concurrent file operations.
240    #[turbo_tasks(debug_ignore, unsafe_ignore)]
241    #[bincode(skip, default = "create_write_semaphore")]
242    write_semaphore: tokio::sync::Semaphore,
243
244    #[turbo_tasks(debug_ignore, unsafe_ignore)]
245    pub(crate) watcher: DiskWatcher,
246    /// Root paths that we do not allow access to from this filesystem.
247    /// Useful for things like output directories to prevent accidental ouroboros situations.
248    denied_paths: Vec<RcStr>,
249    /// Used by invalidators when called from a non-turbo-tasks thread, specifically in the fs
250    /// watcher.
251    #[turbo_tasks(debug_ignore, unsafe_ignore)]
252    #[bincode(skip, default = "turbo_tasks_weak")]
253    pub(crate) turbo_tasks: Weak<dyn TurboTasksApi>,
254    /// Used by invalidators when called from a non-tokio thread, specifically in the fs watcher.
255    #[turbo_tasks(debug_ignore, unsafe_ignore)]
256    #[bincode(skip, default = "Handle::current")]
257    pub(crate) tokio_handle: Handle,
258    #[turbo_tasks(debug_ignore, unsafe_ignore)]
259    #[bincode(skip)]
260    effect_state_storage: EffectStateStorage,
261    map: OperationVc<DiskFileSystemMap>,
262}
263
264impl DiskFileSystemInner {
265    pub(crate) fn root_path(&self) -> &Path {
266        Path::new(&*self.root)
267    }
268
269    /// Checks if a path is within the denied path
270    /// Returns true if the path should be treated as non-existent
271    ///
272    /// Since denied_paths are guaranteed to be:
273    /// - normalized (no ../ traversals)
274    /// - using unix separators (/)
275    /// - relative to the fs root
276    ///
277    /// We can efficiently check using string operations
278    fn is_path_denied(&self, path: &FileSystemPath) -> bool {
279        let path = &path.path;
280        self.denied_paths.iter().any(|denied_path| {
281            path.starts_with(denied_path.as_str())
282                && (path.len() == denied_path.len()
283                    || path.as_bytes().get(denied_path.len()) == Some(&b'/'))
284        })
285    }
286
287    /// registers the path as an invalidator for the current task,
288    /// has to be called within a turbo-tasks function
289    async fn register_read_invalidator(&self, path: &Arc<PathBuf>) -> Result<()> {
290        if let Some(invalidator) = turbo_tasks::get_invalidator() {
291            self.invalidator_map.insert(path.clone(), invalidator);
292            self.watcher
293                .ensure_watched_file(path, self.root_path())
294                .await?;
295        }
296        Ok(())
297    }
298
299    /// After an effect writes to a path, invalidate any read tasks tracking that path so they
300    /// re-read the updated content. This is necessary because the file watcher may not be active
301    /// (e.g., in tests or build-only scenarios).
302    fn invalidate_from_write(&self, full_path: &Path) {
303        let mut invalidator_map = self.invalidator_map.lock().unwrap();
304        if let Some(invalidators) = invalidator_map.remove(full_path) {
305            let Some(turbo_tasks) = self.turbo_tasks.upgrade() else {
306                return;
307            };
308            let _guard = self.tokio_handle.enter();
309            let reason = Write {
310                path: full_path.to_string_lossy().into_owned(),
311            };
312            Invalidator::invalidate_all_with_reason(invalidators, &*turbo_tasks, reason);
313        }
314    }
315
316    /// registers the path as an invalidator for the current task,
317    /// has to be called within a turbo-tasks function
318    async fn register_dir_invalidator(&self, path: &Arc<PathBuf>) -> Result<()> {
319        if let Some(invalidator) = turbo_tasks::get_invalidator() {
320            self.dir_invalidator_map.insert(path.clone(), invalidator);
321            self.watcher
322                .ensure_watched_dir(path, self.root_path())
323                .await?;
324        }
325        Ok(())
326    }
327
328    async fn lock_path(&self, full_path: Arc<PathBuf>) -> PathLockGuard<'_> {
329        let lock1 = self.invalidation_lock.read().await;
330        let lock2 = self.mutex_map.lock(full_path).await;
331        PathLockGuard(lock1, lock2)
332    }
333
334    pub(crate) fn invalidate(&self) {
335        let _span = tracing::info_span!("invalidate filesystem", name = &*self.root).entered();
336        let Some(turbo_tasks) = self.turbo_tasks.upgrade() else {
337            return;
338        };
339        let _guard = self.tokio_handle.enter();
340
341        let invalidator_map = take(&mut *self.invalidator_map.lock().unwrap());
342        let dir_invalidator_map = take(&mut *self.dir_invalidator_map.lock().unwrap());
343        let invalidators = invalidator_map
344            .into_iter()
345            .chain(dir_invalidator_map)
346            .flat_map(|(_, invalidators)| invalidators.into_iter());
347        Invalidator::invalidate_all(invalidators, &*turbo_tasks);
348    }
349
350    /// Invalidates every tracked file in the filesystem.
351    ///
352    /// Calls the given `reason` closure to find the [`InvalidationReason`].
353    pub(crate) fn invalidate_with_reason<R: InvalidationReason>(
354        &self,
355        reason: impl Fn(&Path) -> R,
356    ) {
357        let _span = tracing::info_span!("invalidate filesystem", name = &*self.root).entered();
358        let Some(turbo_tasks) = self.turbo_tasks.upgrade() else {
359            return;
360        };
361        let _guard = self.tokio_handle.enter();
362
363        let invalidator_map = take(&mut *self.invalidator_map.lock().unwrap());
364        let dir_invalidator_map = take(&mut *self.dir_invalidator_map.lock().unwrap());
365        let path_invalidator_counts = || {
366            invalidator_map
367                .values()
368                .chain(dir_invalidator_map.values())
369                .map(|path_invalidators| path_invalidators.len())
370                .filter(|&count| count > 0)
371        };
372        let mut invalidators = Vec::with_capacity(path_invalidator_counts().sum());
373        let mut reasons = Vec::with_capacity(path_invalidator_counts().count());
374        for (path, path_invalidators) in invalidator_map.into_iter().chain(dir_invalidator_map) {
375            if path_invalidators.is_empty() {
376                continue;
377            }
378            reasons.push(reason(&path));
379            invalidators.extend(path_invalidators);
380        }
381        Invalidator::invalidate_all_with_reasons(invalidators, &*turbo_tasks, reasons);
382    }
383
384    /// Invalidates tracked files/directories for `paths` and their children.
385    /// Also invalidates tracked directory reads for all parent directories to
386    /// account for file creations/deletions under the deferred subtree.
387    fn invalidate_path_and_children_with_reason<R: InvalidationReason>(
388        &self,
389        paths: impl IntoIterator<Item = PathBuf>,
390        reason: impl Fn(&Path) -> R,
391    ) {
392        let _span =
393            tracing::info_span!("invalidate filesystem paths", name = &*self.root).entered();
394        let Some(turbo_tasks) = self.turbo_tasks.upgrade() else {
395            return;
396        };
397        let _guard = self.tokio_handle.enter();
398
399        let paths = paths.into_iter();
400        let path_count = paths.size_hint().0;
401        let mut invalidator_map = self.invalidator_map.lock().unwrap();
402        let mut dir_invalidator_map = self.dir_invalidator_map.lock().unwrap();
403        let mut invalidators = Vec::with_capacity(path_count);
404        let mut reasons = Vec::with_capacity(path_count);
405        let mut add = |path: &Path, path_invalidators: FxHashSet<Invalidator>| {
406            if !path_invalidators.is_empty() {
407                reasons.push(reason(path));
408                invalidators.extend(path_invalidators);
409            }
410        };
411        let mut parent_dirs_to_invalidate = FxHashSet::default();
412
413        for path in paths {
414            let mut current_parent = path.parent();
415            while let Some(parent) = current_parent {
416                parent_dirs_to_invalidate.insert(parent.to_path_buf());
417                current_parent = parent.parent();
418            }
419
420            for (invalidated_path, path_invalidators) in
421                invalidator_map.extract_path_with_children(&path)
422            {
423                add(&invalidated_path, path_invalidators);
424            }
425
426            for (invalidated_path, path_invalidators) in
427                dir_invalidator_map.extract_path_with_children(&path)
428            {
429                add(&invalidated_path, path_invalidators);
430            }
431        }
432
433        for path in parent_dirs_to_invalidate {
434            if let Some(path_invalidators) = dir_invalidator_map.remove(path.as_path()) {
435                add(&path, path_invalidators);
436            }
437        }
438
439        drop(invalidator_map);
440        drop(dir_invalidator_map);
441
442        Invalidator::invalidate_all_with_reasons(invalidators, &*turbo_tasks, reasons);
443    }
444}
445
446/// `DiskFileSystem` carries serializable fields (`name`, `root`,
447/// `denied_paths`) inside `DiskFileSystemInner` alongside session-scoped
448/// state (the `notify` watcher, invalidator maps, weak `TurboTasksApi`,
449/// etc.) This is important to maintain invariants in a session and ensure invalidations work, so we
450/// never evict this data.
451#[derive(Clone, ValueToString)]
452#[value_to_string(self.inner.name)]
453#[turbo_tasks::value(cell = "new", eq = "manual", evict = "never")]
454pub struct DiskFileSystem {
455    inner: Arc<DiskFileSystemInner>,
456}
457
458impl DiskFileSystem {
459    pub fn name(&self) -> &RcStr {
460        &self.inner.name
461    }
462
463    pub fn root(&self) -> &RcStr {
464        &self.inner.root
465    }
466
467    #[cfg(debug_assertions)]
468    async fn ensure_path_is_realpath(&self, operation: &str, path: &Path) -> Result<()> {
469        if let Ok(realpath) = retry_blocking(|| std::fs::canonicalize(path))
470            .instrument(tracing::info_span!("realpath for filesystem read", name = ?path))
471            .concurrency_limited(&self.inner.read_semaphore)
472            .await
473            && realpath != path
474        {
475            anyhow::bail!(
476                "{operation} called with unresolved path {path:?}; resolve it to {realpath:?} \
477                 first"
478            );
479        }
480        Ok(())
481    }
482
483    pub fn invalidate(&self) {
484        self.inner.invalidate();
485    }
486
487    pub fn invalidate_with_reason<R: InvalidationReason + Clone>(
488        &self,
489        reason: impl Fn(&Path) -> R + Sync,
490    ) {
491        self.inner.invalidate_with_reason(reason);
492    }
493
494    pub fn invalidate_path_and_children_with_reason<R: InvalidationReason + Clone>(
495        &self,
496        paths: impl IntoIterator<Item = PathBuf>,
497        reason: impl Fn(&Path) -> R + Sync,
498    ) {
499        self.inner
500            .invalidate_path_and_children_with_reason(paths, reason);
501    }
502
503    pub async fn start_watching(&self) -> Result<()> {
504        DiskWatcher::start_watching(self.inner.clone()).await
505    }
506
507    pub async fn stop_watching(&self) {
508        self.inner.watcher.stop_watching().await;
509    }
510
511    /// Try to convert [`Path`] to [`FileSystemPath`]. Return `None` if the file path leaves the
512    /// filesystem root. If no `relative_to` argument is given, it is assumed that the `sys_path` is
513    /// relative to the [`DiskFileSystem`] root.
514    ///
515    /// Attempts to convert absolute paths to paths relative to the filesystem root, though we only
516    /// attempt to do so lexically.
517    ///
518    /// Assumes `self` is the `DiskFileSystem` contained in `vc_self`. This API is a bit awkward
519    /// because:
520    /// - [`Path`]/[`PathBuf`] should not be stored in the filesystem cache, so the function cannot
521    ///   be a [`turbo_tasks::function`].
522    /// - It's a little convenient for this function to be sync.
523    pub fn try_from_sys_path(
524        &self,
525        vc_self: ResolvedVc<DiskFileSystem>,
526        sys_path: &Path,
527        relative_to: Option<&FileSystemPath>,
528    ) -> Option<FileSystemPath> {
529        let vc_self = ResolvedVc::upcast(vc_self);
530
531        let relative_sys_path = if sys_path.is_absolute() {
532            // Flatten any `..` or `.` components. `normalize_lexically` will return an error if the
533            // relative `sys_path` leaves the system root.
534            #[cfg(not(windows))]
535            let normalized_sys_path = sys_path.normalize_lexically().ok()?;
536
537            // Unlike `std::fs::canonicalize`, this is a purely lexical operation: it does not
538            // resolve symlinks or 8.3 short name format.
539            #[cfg(windows)]
540            let normalized_sys_path = to_verbatim_with_case_folded_disk(sys_path).ok()?;
541
542            normalized_sys_path
543                .strip_prefix(self.inner.root_path())
544                .ok()?
545                .to_owned()
546        } else {
547            // we always have to prepend and then strip root_sys_path here. Imagine:
548            // root_sys_path = "/a/b"
549            // sys_path = "../b/c"
550            // relative_to = None
551            //
552            // The resulting path would be "/a/b/c", which is inside the `root_sys_path`, but we can
553            // only figure that out if we start from `root_sys_path`.
554            let root_sys_path = self.inner.root_path();
555            let relative_to_sys_path = if let Some(relative_to) = relative_to {
556                debug_assert_eq!(
557                    relative_to.fs, vc_self,
558                    "`relative_to.fs` must match the current `ResolvedVc<DiskFileSystem>`"
559                );
560                root_sys_path
561                    .join(Path::new(&*unix_to_sys(&relative_to.path)))
562                    .join(sys_path)
563            } else {
564                root_sys_path.join(sys_path)
565            };
566            relative_to_sys_path
567                .normalize_lexically()
568                .ok()?
569                .strip_prefix(root_sys_path)
570                .ok()?
571                .to_owned()
572        };
573
574        Some(FileSystemPath {
575            fs: vc_self,
576            path: RcStr::from(sys_to_unix(relative_sys_path.to_str()?)),
577        })
578    }
579
580    /// Similar to [`try_from_sys_path`], but allows resolving paths that cross roots and may be in
581    /// a different [`DiskFileSystem`] linked via the [`DiskFileSystemMap`].
582    ///
583    /// Unlike [`try_from_sys_path`], this conversion is not entirely lexical, and we may attempt to
584    /// canonicalize some segments of an absolute path.
585    ///
586    /// Crossing roots should generally only happen at symlink boundaries. If you are not
587    /// handling a path that was potentially formed by crossing a symlink boundary, you should
588    /// prefer [`try_from_sys_path`].
589    ///
590    /// [`try_from_sys_path`]: DiskFileSystem::try_from_sys_path
591    pub async fn try_from_sys_path_across_roots(
592        &self,
593        vc_self: ResolvedVc<DiskFileSystem>,
594        sys_path: &Path,
595        relative_to: &FileSystemPath,
596    ) -> Result<Option<FileSystemPath>> {
597        // The non-lexical fallback is an untracked read of state the watcher can't see (outside
598        // the filesystem root), and is not portable across machines, hence it is
599        // `session_dependent`. That also makes it okay to take a system path as a task input, as
600        // the result is never reused across sessions.
601        #[turbo_tasks::function(fs, session_dependent)]
602        async fn slow_non_lexical(
603            vc_self: ResolvedVc<DiskFileSystem>,
604            absolute_path: RcStr,
605        ) -> Result<Vc<FileSystemPathOption>> {
606            let this = vc_self.await?;
607            let absolute_path = Path::new(&*absolute_path);
608            let path = this
609                .resolve_path_ancestry_slow_path(vc_self, absolute_path)
610                .await?;
611            Ok(Vc::cell(path))
612        }
613
614        debug_assert_eq!(
615            relative_to.fs,
616            ResolvedVc::upcast(vc_self),
617            "`relative_to` must be in the current disk filesystem"
618        );
619
620        if let Some(path) = self.try_from_sys_path(vc_self, sys_path, Some(relative_to)) {
621            return Ok(Some(path));
622        }
623
624        let absolute_path = self
625            .to_sys_path_raw(relative_to)
626            .join(sys_path)
627            .normalize_lexically()?;
628        let Some(absolute_path) = absolute_path.to_str() else {
629            return Ok(None);
630        };
631        slow_non_lexical(*vc_self, RcStr::from(absolute_path))
632            .owned()
633            .await
634    }
635
636    /// Returns the path as a system [`PathBuf`]. Similar to [`DiskFileSystem::to_sys_path`], but
637    /// keeps the internal representation as-is.
638    ///
639    ///
640    /// On Windows this returns the verbatim/extended (`\\?\`-prefixed) path format used internally,
641    /// which supports paths exceeding the 260 character path limit. Use this for internal
642    /// filesystem operations and for comparisons against other internal paths, such as the keys of
643    /// the invalidator maps used by [`Self::invalidate_path_and_children_with_reason`]. For a path
644    /// handed to external consumers, prefer [`Self::to_sys_path`].
645    ///
646    /// On non-Windows platforms this is identical to [`Self::to_sys_path`].
647    pub fn to_sys_path_raw(&self, fs_path: &FileSystemPath) -> PathBuf {
648        let sys_root = self.inner.root_path();
649        if fs_path.path.is_empty() {
650            sys_root.to_path_buf()
651        } else {
652            sys_root.join(&*unix_to_sys(&fs_path.path))
653        }
654    }
655
656    /// Returns the path as a system [`PathBuf`].
657    ///
658    /// As a general rule, system paths should not be stored inside turbo-task cells, or passed
659    /// inside [`turbo_tasks::TaskInput`]s as they are not valid after serialization.
660    ///
661    /// On Windows, this will attempt to convert the internal verbatim/extended path representation
662    /// to a more-compatible win32 path, but may return a verbatim path if that conversion fails.
663    pub fn to_sys_path(&self, fs_path: &FileSystemPath) -> PathBuf {
664        let sys_path = self.to_sys_path_raw(fs_path);
665
666        #[cfg(windows)]
667        return sys_path.to_winuser_path().unwrap_or(sys_path);
668        #[cfg(not(windows))]
669        return sys_path;
670    }
671
672    /// Resolves an absolute path into this filesystem or another configured filesystem by
673    /// canonicalizing successive prefixes as needed. For performance reasons, the caller should
674    /// attempt to perform lexical prefix stripping against the current root before calling this
675    /// method.
676    ///
677    /// Returns [`None`] if the path never reaches a filesystem root, an ancestor can't be
678    /// canonicalized, or the first matching prefix lands inside the root.
679    ///
680    /// In some cases that absolute path may contain symlinks, different capitalization, or Windows
681    /// 8.3 short paths. Resolving this requires performing untracked IO outside of the filesystem
682    /// root, where we have no filesystem watcher configured. This is mostly okay as we assume the
683    /// [`DiskFileSystem`] root is stable.
684    ///
685    /// A prefix that first lands inside the root may have followed an internal symlink through
686    /// untracked IO. Only an exact root match is valid; leave the remaining path untouched.
687    async fn resolve_path_ancestry_slow_path(
688        &self,
689        vc_self: ResolvedVc<Self>,
690        target_sys_path: &Path,
691    ) -> Result<Option<FileSystemPath>> {
692        // Assumption: The map only changes across process restarts, and the map is updated upon
693        // process start (e.g. when processing the Turbopack config) before any root tasks depending
694        // on this (e.g. building bundled outputs) are alive, so in-memory caches that depend on
695        // this value are okay.
696        let map = self.inner.map.connect().await?;
697
698        // The fs map could be empty (i.e. this DiskFileSystem doesn't use additional roots), or
699        // (less likely) it may not contain the current root (e.g. paths are allowed to navigate out
700        // of this root, but not back in).
701        //
702        // For these cases, try to match against the current root before looking at the map. Use the
703        // cache stored on the `DiskFileSystem` instead of the one on the `DiskFileSystemMap`.
704        if !map.contains(self.inner.root_path(), vc_self) {
705            if let Some(found) = self
706                .inner
707                .root_prefixes
708                .walk_canonicalized_ancestry(target_sys_path, |canonical| {
709                    let root_path = self.inner.root_path();
710                    if canonical == root_path {
711                        ControlFlow::Break(Some(vc_self))
712                    } else if canonical.starts_with(root_path) {
713                        // Unlikely: We unexpectedly ended up deep *inside* of `root_path`, which
714                        // means we did some untracked reads inside of the filesystem root, just
715                        // bail out and treat this path as invalid.
716                        ControlFlow::Break(None)
717                    } else {
718                        ControlFlow::Continue(())
719                    }
720                })
721                .await
722            {
723                return Ok(Some(found));
724            }
725            if map.is_empty() {
726                // don't bother canonicalizing paths again and checking the map if there's no map
727                return Ok(None);
728            }
729        }
730
731        // Less slow path: Lexically compare against the map. The caller of this function should
732        // already tried to lexically compare with the current root, but not the full map.
733        if let Some(found) = target_sys_path
734            .normalize_lexically()
735            .ok()
736            .and_then(|path| map.lookup_fs_path(&path))
737        {
738            return Ok(Some(found));
739        }
740
741        // Slow path: Try to canonicalize and compare each ancestor against the map.
742        Ok(map
743            .canonicalized_paths
744            .walk_canonicalized_ancestry(target_sys_path, |canonical| {
745                let Some(found) = map.lookup_sys_path_suffix(canonical) else {
746                    return ControlFlow::Continue(());
747                };
748                if found.remaining.as_os_str().is_empty() {
749                    ControlFlow::Break(Some(found.fs))
750                } else {
751                    ControlFlow::Break(None)
752                }
753            })
754            .await)
755    }
756}
757
758#[allow(dead_code, reason = "we need to hold onto the locks")]
759struct PathLockGuard<'a>(
760    #[allow(dead_code)] RwLockReadGuard<'a, ()>,
761    #[allow(dead_code)] crate::mutex_map::MutexMapGuard<'a, Arc<PathBuf>>,
762);
763
764pub(crate) fn format_absolute_fs_path(path: &Path, name: &str, root_path: &Path) -> Option<String> {
765    if let Ok(rel_path) = path.strip_prefix(root_path) {
766        let path = if MAIN_SEPARATOR != '/' {
767            let rel_path = rel_path.to_string_lossy().replace(MAIN_SEPARATOR, "/");
768            format!("[{name}]/{rel_path}")
769        } else {
770            format!("[{name}]/{}", rel_path.display())
771        };
772        Some(path)
773    } else {
774        None
775    }
776}
777
778impl DiskFileSystem {
779    /// Create a new instance of `DiskFileSystem`.
780    ///
781    /// `name` is a display name for the filesystem. This should be unique.
782    ///
783    /// `root` is the [canonicalized][std::fs::canonicalize] root of the filesystem. It should have
784    /// a stable [cell identity][`ResolvedVc`] to avoid invalidating every path if the root changes.
785    ///
786    /// This API does not canonicalize itself, as that requires IO operations (e.g. symlink
787    /// resolution) which should (ideally) not be cached.
788    pub fn new(name: RcStr, root: Vc<RcStr>) -> Vc<Self> {
789        Self::new_internal(
790            name,
791            root,
792            Vec::new(),
793            DiskWatcherConfig::default(),
794            DiskFileSystemMap::empty(),
795        )
796    }
797
798    /// An extended version of [`DiskFileSystem::new`].
799    ///
800    /// `denied_paths` contains normalized Unix-style paths relative to `root` that
801    /// [`DiskFileSystem`] will treat as nonexistent, disallowing reads of files in those
802    /// directories.
803    ///
804    /// `watcher_config` controls how filesystem changes are detected and reported. See
805    /// [`DiskWatcherConfig`].
806    ///
807    /// `map` provides other configured filesystems used to resolve symlink targets that leave
808    /// this filesystem's root.
809    pub fn new_with_options(
810        name: RcStr,
811        root: Vc<RcStr>,
812        denied_paths: Vec<RcStr>,
813        watcher_config: DiskWatcherConfig,
814        map: OperationVc<DiskFileSystemMap>,
815    ) -> Vc<Self> {
816        for denied_path in &denied_paths {
817            debug_assert!(!denied_path.is_empty(), "denied_path must not be empty");
818            debug_assert!(
819                normalize_path(denied_path).as_deref() == Some(&**denied_path),
820                "denied_path must be normalized: {denied_path:?}"
821            );
822        }
823        Self::new_internal(name, root, denied_paths, watcher_config, map)
824    }
825}
826
827#[turbo_tasks::value_impl]
828impl DiskFileSystem {
829    #[turbo_tasks::function]
830    async fn new_internal(
831        name: RcStr,
832        root: Vc<RcStr>,
833        denied_paths: Vec<RcStr>,
834        watcher_config: DiskWatcherConfig,
835        map: OperationVc<DiskFileSystemMap>,
836    ) -> Result<Vc<Self>> {
837        let root = root.owned().await?;
838        let instance = DiskFileSystem {
839            inner: Arc::new(DiskFileSystemInner {
840                name,
841                root,
842                root_prefixes: Default::default(),
843                mutex_map: Default::default(),
844                invalidation_lock: Default::default(),
845                invalidator_map: InvalidatorMap::new(),
846                dir_invalidator_map: InvalidatorMap::new(),
847                read_semaphore: create_read_semaphore(),
848                write_semaphore: create_write_semaphore(),
849                watcher: DiskWatcher::new(watcher_config),
850                denied_paths,
851                turbo_tasks: turbo_tasks_weak(),
852                tokio_handle: Handle::current(),
853                effect_state_storage: EffectStateStorage::default(),
854                map,
855            }),
856        };
857
858        Ok(Self::cell(instance))
859    }
860}
861
862impl Debug for DiskFileSystem {
863    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
864        write!(f, "name: {}, root: {}", self.inner.name, self.inner.root)
865    }
866}
867
868#[turbo_tasks::value_impl]
869impl FileSystem for DiskFileSystem {
870    #[turbo_tasks::function(fs, session_dependent)]
871    async fn read(&self, fs_path: FileSystemPath) -> Result<Vc<FileContent>> {
872        // Check if path is denied - if so, treat as NotFound
873        if self.inner.is_path_denied(&fs_path) {
874            return Ok(FileContent::NotFound.cell());
875        }
876        let full_path = Arc::new(self.to_sys_path_raw(&fs_path));
877
878        self.inner.register_read_invalidator(&full_path).await?;
879
880        let _lock = self.inner.lock_path(full_path.clone()).await;
881        let content = match retry_blocking(|| File::from_path(&full_path))
882            .instrument(tracing::info_span!("read file", name = ?full_path))
883            .concurrency_limited(&self.inner.read_semaphore)
884            .await
885        {
886            Ok(file) => {
887                #[cfg(debug_assertions)]
888                self.ensure_path_is_realpath("read_file", &full_path)
889                    .await?;
890                FileContent::new(file)
891            }
892            Err(e) if e.kind() == ErrorKind::NotFound || e.kind() == ErrorKind::InvalidFilename => {
893                FileContent::NotFound
894            }
895            // ast-grep-ignore: no-context-format
896            Err(e) => return Err(anyhow!(e).context(format!("reading file {full_path:?}"))),
897        };
898        Ok(content.cell())
899    }
900
901    #[turbo_tasks::function(fs, session_dependent)]
902    async fn raw_read_dir(&self, fs_path: FileSystemPath) -> Result<Vc<RawDirectoryContent>> {
903        // Check if directory itself is denied - if so, treat as NotFound
904        if self.inner.is_path_denied(&fs_path) {
905            return Ok(RawDirectoryContent::not_found());
906        }
907        let full_path = Arc::new(self.to_sys_path_raw(&fs_path));
908
909        self.inner.register_dir_invalidator(&full_path).await?;
910
911        // we use the sync std function here as it's a lot faster (600%) in node-file-trace
912        let read_dir = match retry_blocking(|| std::fs::read_dir(&*full_path))
913            .instrument(tracing::info_span!("read directory", name = ?full_path))
914            .concurrency_limited(&self.inner.read_semaphore)
915            .await
916        {
917            Ok(dir) => {
918                #[cfg(debug_assertions)]
919                self.ensure_path_is_realpath("read_dir", &full_path).await?;
920                dir
921            }
922            Err(e)
923                if e.kind() == ErrorKind::NotFound
924                    || e.kind() == ErrorKind::NotADirectory
925                    || e.kind() == ErrorKind::InvalidFilename =>
926            {
927                return Ok(RawDirectoryContent::not_found());
928            }
929            Err(e) => {
930                // ast-grep-ignore: no-context-format
931                return Err(anyhow!(e).context(format!("reading dir {full_path:?}")));
932            }
933        };
934        let dir_path = fs_path.path.as_str();
935        let denied_entries: FxHashSet<&str> = self
936            .inner
937            .denied_paths
938            .iter()
939            .filter_map(|denied_path| {
940                // If we have a denied path, we need to see if the current directory is a prefix of
941                // the denied path meaning that it is possible that some directory entry needs to be
942                // filtered. we do this first to avoid string manipulation on every
943                // iteration of the directory entries. So while expanding `foo/bar`,
944                // if `foo/bar/baz` is denied, we filter out `baz`.
945                // But if foo/bar/baz/qux is denied we don't filter anything from this level.
946                if denied_path.starts_with(dir_path) {
947                    let denied_path_suffix =
948                        if denied_path.as_bytes().get(dir_path.len()) == Some(&b'/') {
949                            Some(&denied_path[dir_path.len() + 1..])
950                        } else if dir_path.is_empty() {
951                            Some(denied_path.as_str())
952                        } else {
953                            None
954                        };
955                    // if the suffix is `foo/bar` we cannot filter foo from this level
956                    denied_path_suffix.filter(|s| !s.contains('/'))
957                } else {
958                    None
959                }
960            })
961            .collect();
962
963        let entries = read_dir
964            .filter_map(|r| {
965                let e = match r {
966                    Ok(e) => e,
967                    Err(err) => return Some(Err(err.into())),
968                };
969
970                // we filter out any non unicode names
971                let file_name = RcStr::from(e.file_name().to_str()?);
972                // Filter out denied entries
973                if denied_entries.contains(file_name.as_str()) {
974                    return None;
975                }
976
977                let entry = match e.file_type() {
978                    Ok(t) if t.is_file() => RawDirectoryEntry::File,
979                    Ok(t) if t.is_dir() => RawDirectoryEntry::Directory,
980                    Ok(t) if t.is_symlink() => RawDirectoryEntry::Symlink,
981                    Ok(_) => RawDirectoryEntry::Other,
982                    Err(err) => return Some(Err(err.into())),
983                };
984
985                Some(anyhow::Ok((file_name, entry)))
986            })
987            .collect::<Result<_>>()
988            .with_context(|| format!("reading directory item in {full_path:?}"))?;
989
990        Ok(RawDirectoryContent::new(entries))
991    }
992
993    #[turbo_tasks::function(fs, session_dependent)]
994    async fn read_link(self: ResolvedVc<Self>, fs_path: FileSystemPath) -> Result<Vc<LinkContent>> {
995        let this = self.await?;
996        let inner = &this.inner;
997        if inner.is_path_denied(&fs_path) {
998            return Ok(LinkContent::Invalid {
999                reason: rcstr!("access to the symlink path is denied"),
1000            }
1001            .cell());
1002        }
1003        let full_link_path = Arc::new(this.to_sys_path_raw(&fs_path));
1004
1005        inner.register_read_invalidator(&full_link_path).await?;
1006
1007        let _lock = inner.lock_path(full_link_path.clone()).await;
1008        let mut target_sys_path = match retry_blocking(|| std::fs::read_link(&**full_link_path))
1009            .instrument(tracing::info_span!("read symlink", name = ?full_link_path))
1010            .concurrency_limited(&inner.read_semaphore)
1011            .await
1012        {
1013            Ok(res) => res,
1014            Err(err) if err.kind() == ErrorKind::NotFound => {
1015                return Ok(LinkContent::NotFound.cell());
1016            }
1017            Err(err) => {
1018                return Ok(LinkContent::Invalid {
1019                    reason: RcStr::from(err.to_string()),
1020                }
1021                .cell());
1022            }
1023        };
1024
1025        if cfg!(windows) && target_sys_path.has_root() && !target_sys_path.is_absolute() {
1026            // On windows, `\foo` has a root but no drive and is not absolute. Just convert it to
1027            // absolute and treat it like it's absolute.
1028            let mut absolute_target = inner.root_path().to_path_buf();
1029            absolute_target.push(target_sys_path);
1030            target_sys_path = absolute_target;
1031        }
1032
1033        let target = if target_sys_path.is_absolute() {
1034            let raw = RcStr::from(sys_to_unix(target_sys_path.to_string_lossy().as_ref()));
1035            // First try a cheap, purely lexical conversion of the raw target. `relative_to` is
1036            // ignored for absolute targets.
1037            let mut target_fs_path = this.try_from_sys_path(self, &target_sys_path, None);
1038
1039            // If that failed, the target may just be spelled differently than our canonicalized
1040            // filesystem root (e.g. case insensitive filesystem, a Windows 8.3 short name, or a
1041            // symlink in the path). This performs session-dependent IO to resolve the fs root
1042            // path.
1043            if target_fs_path.is_none() {
1044                target_fs_path = this
1045                    .resolve_path_ancestry_slow_path(self, &target_sys_path)
1046                    .await?;
1047            }
1048
1049            let Some(target_fs_path) = target_fs_path else {
1050                // The target leaves the filesystem root (or is a dangling link whose parent
1051                // directory couldn't be canonicalized).
1052                return Ok(LinkContent::Invalid {
1053                    reason: rcstr!(
1054                        "the symlink target leaves the filesystem root or its parent directory \
1055                         could not be resolved"
1056                    ),
1057                }
1058                .cell());
1059            };
1060            // Rewrite from the sys root to the DiskFileSystem root.
1061            LinkTarget::Absolute {
1062                raw,
1063                resolved: target_fs_path,
1064            }
1065        } else {
1066            if cfg!(windows)
1067                && let Some(Component::Prefix(target_prefix)) = target_sys_path.components().next()
1068            {
1069                // Edge case: Windows supports relative file paths with a prefixed disk, e.g.
1070                // `C:foo`. These only make sense when the drive letter matches the
1071                // DiskFileSystem root. If it matches, we can safely strip it.
1072                let Prefix::Disk(target_drive) = target_prefix.kind() else {
1073                    unreachable!(
1074                        "path is relative, but contains a prefix that should form an absolute path"
1075                    );
1076                };
1077                let root_drive = match inner.root_path().components().next() {
1078                    Some(Component::Prefix(root_prefix)) => match root_prefix.kind() {
1079                        Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => Some(drive),
1080                        _ => None,
1081                    },
1082                    _ => None,
1083                };
1084                if root_drive
1085                    .is_none_or(|root_drive| !target_drive.eq_ignore_ascii_case(&root_drive))
1086                {
1087                    return Ok(LinkContent::Invalid {
1088                        reason: rcstr!(
1089                            "the symlink target uses a different drive than the filesystem root"
1090                        ),
1091                    }
1092                    .cell());
1093                }
1094
1095                target_sys_path = target_sys_path.components().skip(1).collect();
1096            }
1097
1098            // The raw value read from the link, converted to a unix-style format. A relative
1099            // target is resolved against the directory *containing* the link, not the link itself.
1100            let Some(target_str) = target_sys_path.to_str() else {
1101                return Ok(LinkContent::Invalid {
1102                    reason: RcStr::from(format!(
1103                        "the symlink target {target_sys_path:?} is not valid unicode"
1104                    )),
1105                }
1106                .cell());
1107            };
1108            let raw = RcStr::from(sys_to_unix(target_str));
1109
1110            // If the relative target steps above the root, resolve its absolute system path
1111            // against this filesystem and the other configured roots.
1112            let resolved = if let Some(resolved) = fs_path.parent().try_join(&raw) {
1113                resolved
1114            } else {
1115                let absolute_target = this
1116                    .to_sys_path_raw(&fs_path.parent())
1117                    .join(&target_sys_path)
1118                    .normalize_lexically()
1119                    .ok();
1120                let Some(resolved) = (match absolute_target {
1121                    Some(path) => this.resolve_path_ancestry_slow_path(self, &path).await?,
1122                    None => None,
1123                }) else {
1124                    return Ok(LinkContent::Invalid {
1125                        reason: rcstr!("the symlink target leaves the configured filesystem roots"),
1126                    }
1127                    .cell());
1128                };
1129                resolved
1130            };
1131            LinkTarget::Relative { raw, resolved }
1132        };
1133
1134        Ok(LinkContent::Link { target }.cell())
1135    }
1136
1137    #[turbo_tasks::function(fs, session_dependent)]
1138    async fn is_junction_point(&self, fs_path: FileSystemPath) -> Result<Vc<bool>> {
1139        #[cfg(windows)]
1140        {
1141            if self.inner.is_path_denied(&fs_path) {
1142                return Ok(Vc::cell(false));
1143            }
1144            let full_path = Arc::new(self.to_sys_path_raw(&fs_path));
1145            self.inner.register_read_invalidator(&full_path).await?;
1146
1147            let _lock = self.inner.lock_path(full_path.clone()).await;
1148            let is_junction_point = retry_blocking(|| is_link_junction_point(&full_path))
1149                .instrument(tracing::info_span!("read junction point", name = ?full_path))
1150                .concurrency_limited(&self.inner.read_semaphore)
1151                .await
1152                .with_context(|| format!("checking junction point {full_path:?}"))?;
1153            Ok(Vc::cell(is_junction_point))
1154        }
1155        #[cfg(not(windows))]
1156        {
1157            let _ = fs_path;
1158            Ok(Vc::cell(false))
1159        }
1160    }
1161
1162    #[turbo_tasks::function(fs)]
1163    async fn write(
1164        self: ResolvedVc<Self>,
1165        fs_path: FileSystemPath,
1166        content: ResolvedVc<FileContent>,
1167    ) -> Result<()> {
1168        let this = self.await?;
1169        // You might be tempted to use `session_dependent` here, but `write` purely declares a side
1170        // effect and does not need to be reexecuted in the next session. All side effects are
1171        // reexecuted in general.
1172
1173        // Check if path is denied - if so, return an error
1174        if this.inner.is_path_denied(&fs_path) {
1175            turbobail!("Cannot write to denied path: {fs_path}");
1176        }
1177        let full_path = this.to_sys_path_raw(&fs_path);
1178
1179        // Validate the path length here, when the write is requested, so that any error is
1180        // attributed to the caller rather than surfacing later when the effect is applied.
1181        validate_path_length(&full_path)?;
1182
1183        // Persist the file content so it is stored in the persistent cache.
1184        // Since FileContent uses serialization = "hash", persisting it here ensures the full
1185        // content is available in the persistent cache (via PersistedFileContent) and does not
1186        // require recomputing the content on cache restore — avoiding unnecessary downstream
1187        // recomputation.
1188        let content = content.persist().to_resolved().await?;
1189        let content_hash = hash_xxh3_hash128(&*content.await?);
1190
1191        #[turbo_tasks::value(eq = "manual", cell = "new")]
1192        struct WriteEffect {
1193            full_path: Arc<PathBuf>,
1194            fs: ResolvedVc<DiskFileSystem>,
1195            content: ResolvedVc<PersistedFileContent>,
1196            content_hash: u128,
1197        }
1198
1199        #[async_trait]
1200        #[turbo_tasks::value_impl]
1201        impl Effect for WriteEffect {
1202            async fn capture(&self) -> Result<Box<dyn CapturedEffect>> {
1203                // Untracked, a tracked read of this cell occurred in the write effect so if it
1204                // somehow changes the effect will be re-emitted
1205                let inner = (*self.fs).untracked().await?.inner.clone();
1206
1207                // If the per-key effect state already records `Applied { value_hash }` matching
1208                // our hash, skip materializing the content (avoids a possible disk read +
1209                // decompression via the persistent cache). The apply-time state machine will
1210                // dedup-hit before touching content. If state diverged between this read and
1211                // apply, `Effects::apply` will fire our producer's invalidator via the Retry
1212                // pathway and the producer will rerun with a fresh capture.
1213                let key_bytes: Box<[u8]> = self.full_path.as_os_str().as_encoded_bytes().into();
1214                let content = if inner
1215                    .effect_state_storage
1216                    .matches_applied(&key_bytes, self.content_hash)
1217                {
1218                    None
1219                } else {
1220                    // Untracked: the content cell is already captured via `content_hash`, and
1221                    // we don't want this `capture` to take a tracked dependency on the content
1222                    // cell — that would pin it and defeat the eviction this refactor enables.
1223                    Some((*self.content).untracked().await?)
1224                };
1225                Ok(Box::new(CapturedWriteEffect {
1226                    full_path: self.full_path.clone(),
1227                    inner,
1228                    content,
1229                    content_hash: self.content_hash,
1230                }) as Box<dyn CapturedEffect>)
1231            }
1232        }
1233
1234        #[derive(NonLocalValue, Clone)]
1235        struct CapturedWriteEffect {
1236            full_path: Arc<PathBuf>,
1237            inner: Arc<DiskFileSystemInner>,
1238            content: Option<ReadRef<PersistedFileContent>>,
1239            content_hash: u128,
1240        }
1241
1242        #[async_trait]
1243        impl CapturedEffect for CapturedWriteEffect {
1244            fn key(&self) -> Box<[u8]> {
1245                self.full_path.as_os_str().as_encoded_bytes().into()
1246            }
1247
1248            fn value_hash(&self) -> u128 {
1249                self.content_hash
1250            }
1251
1252            async fn apply(&self) -> Result<(), turbo_tasks::ApplyError> {
1253                let body = self.content.as_ref().map(|content| {
1254                    async || self.apply_inner(content).await.map_err(AnyhowWrapper::from)
1255                });
1256                self.inner
1257                    .effect_state_storage
1258                    .run_apply::<AnyhowWrapper, _, _>(self.key(), self.content_hash, body)
1259                    .await
1260            }
1261        }
1262
1263        impl CapturedWriteEffect {
1264            async fn apply_inner(
1265                &self,
1266                content: &ReadRef<PersistedFileContent>,
1267            ) -> anyhow::Result<()> {
1268                let full_path = &self.full_path;
1269
1270                let _lock = self.inner.lock_path(full_path.clone()).await;
1271
1272                // We perform an untracked comparison here, so that this write is not dependent
1273                // on a read's Vc<FileContent> (and the memory it holds). Our untracked read can
1274                // be freed immediately. Given this is an output file, it's unlikely any Turbo
1275                // code will need to read the file from disk into a Vc<FileContent>, so we're
1276                // not wasting cycles.
1277                let compare = content
1278                    .streaming_compare(full_path)
1279                    .instrument(tracing::info_span!(
1280                        "read file before write",
1281                        name = ?full_path,
1282                    ))
1283                    .concurrency_limited(&self.inner.read_semaphore)
1284                    .await?;
1285                if compare == FileComparison::Equal {
1286                    return Ok(());
1287                }
1288
1289                match &**content {
1290                    PersistedFileContent::Content(..) => {
1291                        let content = content.clone();
1292
1293                        let mut missing_parent_dir = false;
1294                        let do_write = || {
1295                            if missing_parent_dir && let Some(parent) = full_path.parent() {
1296                                std::fs::create_dir_all(parent)?;
1297                                missing_parent_dir = false;
1298                            }
1299                            let mut f = std::fs::File::create(&**full_path).inspect_err(|err| {
1300                                if err.kind() == ErrorKind::NotFound {
1301                                    // create the parent dirs in the next attempt
1302                                    missing_parent_dir = true;
1303                                }
1304                            })?;
1305                            let PersistedFileContent::Content(file) = &*content else {
1306                                unreachable!()
1307                            };
1308                            std::io::copy(&mut file.read(), &mut f)?;
1309                            #[cfg(unix)]
1310                            f.set_permissions(file.meta.permissions.into())?;
1311                            f.flush()?;
1312
1313                            static WRITE_VERSION: LazyLock<bool> = LazyLock::new(|| {
1314                                std::env::var_os("TURBO_ENGINE_WRITE_VERSION")
1315                                    .is_some_and(|v| v == "1" || v == "true")
1316                            });
1317                            if *WRITE_VERSION {
1318                                let mut full_path = (**full_path).clone();
1319                                let hash = hash_xxh3_hash64(file);
1320                                let orig_ext = full_path.extension();
1321                                let mut ext = OsString::from(format!("{hash:016x}"));
1322                                if let Some(orig_ext) = orig_ext {
1323                                    ext.push(".");
1324                                    ext.push(orig_ext);
1325                                }
1326                                full_path.set_extension(ext);
1327                                validate_path_length(&full_path)?;
1328                                let mut f = std::fs::File::create(&*full_path)?;
1329                                std::io::copy(&mut file.read(), &mut f)?;
1330                                #[cfg(unix)]
1331                                f.set_permissions(file.meta.permissions.into())?;
1332                                f.flush()?;
1333                            }
1334                            Ok::<(), io::Error>(())
1335                        };
1336                        fn can_retry_write(err: &io::Error) -> bool {
1337                            err.kind() == ErrorKind::NotFound || can_retry(err)
1338                        }
1339                        retry_blocking_custom(do_write, can_retry_write)
1340                            .instrument(tracing::info_span!("write file", name = ?full_path))
1341                            .concurrency_limited(&self.inner.write_semaphore)
1342                            .await
1343                            .with_context(|| format!("failed to write to {full_path:?}"))?;
1344                    }
1345                    PersistedFileContent::NotFound => {
1346                        retry_blocking(|| std::fs::remove_file(&**full_path))
1347                            .instrument(tracing::info_span!("remove file", name = ?full_path))
1348                            .concurrency_limited(&self.inner.write_semaphore)
1349                            .await
1350                            .or_else(|err| {
1351                                if err.kind() == ErrorKind::NotFound {
1352                                    Ok(())
1353                                } else {
1354                                    Err(err)
1355                                }
1356                            })
1357                            .with_context(|| format!("removing {full_path:?} failed"))?;
1358                    }
1359                }
1360
1361                // Invalidate any read tasks tracking this path so they re-read the new content
1362                self.inner.invalidate_from_write(&self.full_path);
1363
1364                Ok(())
1365            }
1366        }
1367
1368        WriteEffect {
1369            full_path: Arc::new(full_path),
1370            fs: self,
1371            content,
1372            content_hash,
1373        }
1374        .resolved_cell()
1375        .emit();
1376
1377        Ok(())
1378    }
1379
1380    #[turbo_tasks::function(fs)]
1381    async fn write_link(
1382        self: ResolvedVc<Self>,
1383        fs_path: FileSystemPath,
1384        target: ResolvedVc<WriteLinkContent>,
1385    ) -> Result<()> {
1386        // You might be tempted to use `session_dependent` here, but we purely declare a side
1387        // effect and does not need to be re-executed in the next session. All side effects are
1388        // re-executed in general.
1389
1390        let this = self.await?;
1391        // Check if path is denied - if so, return an error
1392        if this.inner.is_path_denied(&fs_path) {
1393            turbobail!("Cannot write link to denied path: {fs_path}");
1394        }
1395        let full_path = Arc::new(this.to_sys_path_raw(&fs_path));
1396
1397        validate_path_length(&full_path)?;
1398
1399        let content = target.await?;
1400
1401        let target_fs = ResolvedVc::try_downcast_type::<DiskFileSystem>(content.target.fs)
1402            .context("link target must use a disk filesystem")?
1403            .await?;
1404        let target_abs_sys_path = target_fs.to_sys_path_raw(&content.target);
1405        let target_type = content.target_type.clone();
1406        let is_directory = matches!(target_type, WriteLinkTargetType::DirectoryOrJunctionPoint);
1407        // Prefer to write relative links.
1408        //
1409        // Windows: Junction points require absolute paths. `pathdiff` may return an absolute path
1410        // if paths cross drives.
1411        let target_sys_path = if cfg!(windows) && is_directory {
1412            None
1413        } else {
1414            full_path
1415                .parent()
1416                .and_then(|parent| pathdiff::diff_paths(&target_abs_sys_path, parent))
1417        };
1418        let target_sys_path = match target_sys_path {
1419            Some(target_sys_path) if target_sys_path.as_os_str().is_empty() => PathBuf::from("."),
1420            Some(target_sys_path) => target_sys_path,
1421            None => target_abs_sys_path,
1422        };
1423        let target_sys_path = Arc::new(target_sys_path);
1424        let content_hash =
1425            hash_xxh3_hash128((target_sys_path.as_os_str().as_encoded_bytes(), &target_type));
1426
1427        #[turbo_tasks::value(eq = "manual", cell = "new")]
1428        struct WriteLinkEffect {
1429            fs: ResolvedVc<DiskFileSystem>,
1430            full_path: Arc<PathBuf>,
1431            target_sys_path: Arc<PathBuf>,
1432            target_type: WriteLinkTargetType,
1433            content_hash: u128,
1434        }
1435
1436        #[async_trait]
1437        #[turbo_tasks::value_impl]
1438        impl Effect for WriteLinkEffect {
1439            async fn capture(&self) -> Result<Box<dyn CapturedEffect>> {
1440                // Untracked, a tracked read of this cell occurred in the write effect so if it
1441                // somehow changes the effect will be re-emitted
1442                let inner = (*self.fs).untracked().await?.inner.clone();
1443
1444                // Skip the write entirely if the per-key effect state already records
1445                // `Applied { value_hash }` matching our hash. See `WriteEffect::capture`.
1446                let key_bytes = self.full_path.as_os_str().as_encoded_bytes();
1447                let content = if inner
1448                    .effect_state_storage
1449                    .matches_applied(key_bytes, self.content_hash)
1450                {
1451                    None
1452                } else {
1453                    Some((self.target_sys_path.clone(), self.target_type.clone()))
1454                };
1455                Ok(Box::new(CapturedWriteLinkEffect {
1456                    full_path: self.full_path.clone(),
1457                    inner,
1458                    content,
1459                    content_hash: self.content_hash,
1460                }) as Box<dyn CapturedEffect>)
1461            }
1462        }
1463
1464        // Post-capture effect — session-only plain struct.
1465        #[derive(NonLocalValue, Clone)]
1466        struct CapturedWriteLinkEffect {
1467            full_path: Arc<PathBuf>,
1468            inner: Arc<DiskFileSystemInner>,
1469            content: Option<(Arc<PathBuf>, WriteLinkTargetType)>,
1470            content_hash: u128,
1471        }
1472
1473        #[async_trait]
1474        impl CapturedEffect for CapturedWriteLinkEffect {
1475            fn key(&self) -> Box<[u8]> {
1476                self.full_path.as_os_str().as_encoded_bytes().into()
1477            }
1478
1479            fn value_hash(&self) -> u128 {
1480                self.content_hash
1481            }
1482
1483            async fn apply(&self) -> Result<(), turbo_tasks::ApplyError> {
1484                let body = self.content.as_ref().map(|content| {
1485                    async || self.apply_inner(content).await.map_err(AnyhowWrapper::from)
1486                });
1487                self.inner
1488                    .effect_state_storage
1489                    .run_apply::<AnyhowWrapper, _, _>(self.key(), self.content_hash, body)
1490                    .await
1491            }
1492        }
1493
1494        impl CapturedWriteLinkEffect {
1495            async fn apply_inner(
1496                &self,
1497                target: &(Arc<PathBuf>, WriteLinkTargetType),
1498            ) -> anyhow::Result<()> {
1499                let full_path = self.full_path.clone();
1500
1501                let _lock = self.inner.lock_path(full_path.clone()).await;
1502
1503                let (target, target_type) = target;
1504                #[cfg(windows)]
1505                let is_directory =
1506                    matches!(target_type, WriteLinkTargetType::DirectoryOrJunctionPoint);
1507                #[cfg(not(windows))]
1508                let _ = target_type;
1509
1510                let old_content = retry_blocking(|| std::fs::read_link(&**full_path))
1511                    .instrument(tracing::info_span!("read symlink before write", name = ?full_path))
1512                    .concurrency_limited(&self.inner.read_semaphore)
1513                    .await
1514                    .ok();
1515                let is_equal = old_content.as_deref() == Some(&**target);
1516                #[cfg(windows)]
1517                let is_equal =
1518                    is_equal && is_link_junction_point(&full_path).ok() == Some(is_directory);
1519                if is_equal {
1520                    return Ok(());
1521                }
1522
1523                #[derive(thiserror::Error, Debug)]
1524                #[error("{msg}: {source}")]
1525                struct SymlinkCreationError {
1526                    msg: &'static str,
1527                    #[source]
1528                    source: io::Error,
1529                }
1530
1531                let mut missing_parent_dir = false;
1532                let mut has_old_content = old_content.is_some();
1533                let try_create_link = || {
1534                    if missing_parent_dir && let Some(parent) = full_path.parent() {
1535                        std::fs::create_dir_all(parent).map_err(|err| SymlinkCreationError {
1536                            msg: "failed to create directory",
1537                            source: err,
1538                        })?;
1539                        missing_parent_dir = false;
1540                    }
1541                    if has_old_content {
1542                        // Remove existing symlink before creating a new one. On Unix,
1543                        // symlink(2) fails with EEXIST if the link already exists instead
1544                        // of overwriting it. Windows has similar behavior with junction
1545                        // points.
1546                        remove_symbolic_link_dir_helper(&full_path).map_err(|err| {
1547                            SymlinkCreationError {
1548                                msg: "removal of existing symbolic link or junction point failed",
1549                                source: err,
1550                            }
1551                        })?;
1552                        has_old_content = false;
1553                    }
1554                    #[cfg(all(not(windows), not(target_os = "wasi")))]
1555                    let io_result = std::os::unix::fs::symlink(&**target, &**full_path);
1556                    #[cfg(target_os = "wasi")]
1557                    let io_result = std::os::wasi::fs::symlink_path(&**target, &**full_path);
1558                    #[cfg(windows)]
1559                    let io_result = if is_directory {
1560                        std::os::windows::fs::junction_point(&**target, &**full_path)
1561                    } else {
1562                        std::os::windows::fs::symlink_file(&**target, &**full_path)
1563                    };
1564                    io_result.map_err(|err| {
1565                        match err.kind() {
1566                            ErrorKind::NotFound => {
1567                                // create the parent dirs in the next attempt
1568                                missing_parent_dir = true;
1569                            }
1570                            ErrorKind::AlreadyExists => {
1571                                // try to remove the symlink on the next attempt
1572                                has_old_content = true;
1573                            }
1574                            _ => {}
1575                        }
1576                        SymlinkCreationError {
1577                            msg: "creation of a new symbolic link or junction point failed",
1578                            source: err,
1579                        }
1580                    })
1581                };
1582                fn can_retry_link(err: &SymlinkCreationError) -> bool {
1583                    matches!(
1584                        err.source.kind(),
1585                        ErrorKind::NotFound | ErrorKind::AlreadyExists
1586                    ) || can_retry(&err.source)
1587                }
1588                let err_context = || {
1589                    #[cfg(not(windows))]
1590                    let message =
1591                        format!("failed to create symlink at {full_path:?} pointing to {target:?}");
1592                    #[cfg(windows)]
1593                    let message = if is_directory {
1594                        format!(
1595                            "failed to create junction point at {full_path:?} pointing to \
1596                             {target:?}"
1597                        )
1598                    } else {
1599                        format!(
1600                            "failed to create symlink at {full_path:?} pointing to \
1601                             {target:?}\n\
1602                            (Note: creating file symlinks on Windows require developer \
1603                             mode or admin permissions: \
1604                             https://learn.microsoft.com/en-us/windows/advanced-settings/developer-mode)",
1605                        )
1606                    };
1607                    message
1608                };
1609                retry_blocking_custom(try_create_link, can_retry_link)
1610                    .instrument(tracing::info_span!(
1611                        "write symlink",
1612                        name = ?full_path,
1613                        target = ?target,
1614                    ))
1615                    .concurrency_limited(&self.inner.write_semaphore)
1616                    .await
1617                    .with_context(err_context)?;
1618
1619                // Invalidate any read tasks tracking this path so they re-read the new content
1620                self.inner.invalidate_from_write(&self.full_path);
1621
1622                Ok(())
1623            }
1624        }
1625
1626        WriteLinkEffect {
1627            fs: self,
1628            full_path,
1629            target_sys_path,
1630            target_type,
1631            content_hash,
1632        }
1633        .resolved_cell()
1634        .emit();
1635        Ok(())
1636    }
1637
1638    #[turbo_tasks::function(fs, session_dependent)]
1639    async fn metadata(&self, fs_path: FileSystemPath) -> Result<Vc<FileMeta>> {
1640        let full_path = Arc::new(self.to_sys_path_raw(&fs_path));
1641
1642        // Check if path is denied - if so, return an error (metadata shouldn't be readable)
1643        if self.inner.is_path_denied(&fs_path) {
1644            turbobail!("Cannot read metadata from denied path: {fs_path}");
1645        }
1646
1647        self.inner.register_read_invalidator(&full_path).await?;
1648
1649        let _lock = self.inner.lock_path(full_path.clone()).await;
1650        let meta = retry_blocking(|| std::fs::metadata(&**full_path))
1651            .instrument(tracing::info_span!("read metadata", name = ?full_path))
1652            .concurrency_limited(&self.inner.read_semaphore)
1653            .await
1654            .with_context(|| format!("reading metadata for {:?}", full_path))?;
1655
1656        Ok(FileMeta::cell(meta.into()))
1657    }
1658}
1659
1660fn remove_symbolic_link_dir_helper(path: &Path) -> io::Result<()> {
1661    let result = if cfg!(windows) {
1662        // Junction points on Windows are treated as directories, and therefore need
1663        // `remove_dir`:
1664        //
1665        // > `RemoveDirectory` can be used to remove a directory junction. Since the target
1666        // > directory and its contents will remain accessible through its canonical path, the
1667        // > target directory itself is not affected by removing a junction which targets it.
1668        //
1669        // -- https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-removedirectoryw
1670        //
1671        // However, Next 16.1.0 shipped with symlinks, before we switched to junction links on
1672        // Windows, and `remove_dir` won't work on symlinks. So try to remove it as a directory
1673        // (junction) first, and then fall back to removing it as a file (symlink).
1674        std::fs::remove_dir(path).or_else(|err| {
1675            if err.kind() == ErrorKind::NotADirectory {
1676                std::fs::remove_file(path)
1677            } else {
1678                Err(err)
1679            }
1680        })
1681    } else {
1682        std::fs::remove_file(path)
1683    };
1684    match result {
1685        Ok(()) => Ok(()),
1686        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
1687        Err(err) => Err(err),
1688    }
1689}
1690
1691#[cfg(test)]
1692mod tests {
1693    use turbo_rcstr::rcstr;
1694    use turbo_tasks::{Effects, OperationVc, Vc, take_effects};
1695    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
1696
1697    use super::*;
1698
1699    #[turbo_tasks::function(operation, root)]
1700    async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result<Vc<Effects>> {
1701        let _ = op.resolve().strongly_consistent().await?;
1702        Ok(take_effects(op).await?.cell())
1703    }
1704
1705    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1706    async fn test_try_from_sys_path() {
1707        let sys_root = if cfg!(windows) {
1708            Path::new(r"C:\fake\root")
1709        } else {
1710            Path::new(r"/fake/root")
1711        };
1712
1713        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
1714            BackendOptions::default(),
1715            noop_backing_storage(),
1716        ));
1717        tt.run_once(async {
1718            assert_try_from_sys_path_operation(RcStr::from(sys_root.to_str().unwrap()))
1719                .read_strongly_consistent()
1720                .await?;
1721
1722            anyhow::Ok(())
1723        })
1724        .await
1725        .unwrap();
1726    }
1727
1728    #[turbo_tasks::function(operation, root)]
1729    async fn assert_try_from_sys_path_operation(sys_root: RcStr) -> anyhow::Result<()> {
1730        let sys_root = Path::new(sys_root.as_str());
1731        let fs_vc = DiskFileSystem::new(
1732            rcstr!("temp"),
1733            Vc::cell(RcStr::from(sys_root.to_str().unwrap())),
1734        )
1735        .to_resolved()
1736        .await?;
1737        let fs = fs_vc.await?;
1738        let fs_root_path = fs_vc.root().await?;
1739
1740        assert_eq!(
1741            fs.try_from_sys_path(
1742                fs_vc,
1743                &Path::new("relative").join("directory"),
1744                /* relative_to */ None,
1745            )
1746            .unwrap()
1747            .path,
1748            "relative/directory"
1749        );
1750
1751        assert_eq!(
1752            fs.try_from_sys_path(
1753                fs_vc,
1754                &sys_root
1755                    .join("absolute")
1756                    .join("directory")
1757                    .join("..")
1758                    .join("normalized_path"),
1759                /* relative_to */ Some(&fs_root_path.join("ignored").unwrap()),
1760            )
1761            .unwrap()
1762            .path,
1763            "absolute/normalized_path"
1764        );
1765
1766        assert_eq!(
1767            fs.try_from_sys_path(
1768                fs_vc,
1769                Path::new("child"),
1770                /* relative_to */ Some(&fs_root_path.join("parent").unwrap()),
1771            )
1772            .unwrap()
1773            .path,
1774            "parent/child"
1775        );
1776
1777        assert_eq!(
1778            fs.try_from_sys_path(
1779                fs_vc,
1780                &Path::new("..").join("parallel_dir"),
1781                /* relative_to */ Some(&fs_root_path.join("parent").unwrap()),
1782            )
1783            .unwrap()
1784            .path,
1785            "parallel_dir"
1786        );
1787
1788        assert_eq!(
1789            fs.try_from_sys_path(
1790                fs_vc,
1791                &Path::new("relative")
1792                    .join("..")
1793                    .join("..")
1794                    .join("leaves_root"),
1795                /* relative_to */ None,
1796            ),
1797            None
1798        );
1799
1800        assert_eq!(
1801            fs.try_from_sys_path(
1802                fs_vc,
1803                &sys_root
1804                    .join("absolute")
1805                    .join("..")
1806                    .join("..")
1807                    .join("leaves_root"),
1808                /* relative_to */ None,
1809            ),
1810            None
1811        );
1812
1813        Ok(())
1814    }
1815
1816    #[cfg(test)]
1817    mod symlink_tests {
1818        use std::{
1819            fs::{File, create_dir_all, read_to_string},
1820            io::Write,
1821        };
1822
1823        use rand::{RngExt, SeedableRng};
1824        use turbo_rcstr::{RcStr, rcstr};
1825        use turbo_tasks::{ResolvedVc, Vc, read_strongly_consistent_and_apply_effects};
1826        use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
1827
1828        use super::extract_effects_operation;
1829        #[cfg(all(unix, debug_assertions))]
1830        use crate::{DirectoryContent, FileContent, RawDirectoryContent};
1831        use crate::{
1832            DiskFileSystem, FileSystem, FileSystemEntryType, FileSystemPath, LinkContent,
1833            LinkTarget, RealPathErrorType, WriteLinkContent, WriteLinkTargetType,
1834            canonicalize_to_rcstr,
1835        };
1836
1837        #[turbo_tasks::function(operation, root)]
1838        async fn test_write_link_effect_operation(
1839            fs: ResolvedVc<DiskFileSystem>,
1840            path: FileSystemPath,
1841            target: RcStr,
1842        ) -> anyhow::Result<()> {
1843            let file_target = path.join(&format!("{target}/data.txt"))?;
1844            let directory_target = path.join(&target)?;
1845            let write_file = |f| {
1846                fs.write_link(
1847                    f,
1848                    WriteLinkContent {
1849                        target: file_target.clone(),
1850                        target_type: WriteLinkTargetType::FileNonPortable,
1851                    }
1852                    .cell(),
1853                )
1854            };
1855            // Write it twice (same content)
1856            write_file(path.join("symlink-file")?).await?;
1857            write_file(path.join("symlink-file")?).await?;
1858
1859            let write_dir = |f| {
1860                fs.write_link(
1861                    f,
1862                    WriteLinkContent {
1863                        target: directory_target.clone(),
1864                        target_type: WriteLinkTargetType::DirectoryOrJunctionPoint,
1865                    }
1866                    .cell(),
1867                )
1868            };
1869            // Write it twice (same content)
1870            write_dir(path.join("symlink-dir")?).await?;
1871            write_dir(path.join("symlink-dir")?).await?;
1872
1873            fs.write_link(
1874                path.join("symlink-parent")?,
1875                WriteLinkContent {
1876                    target: path,
1877                    target_type: WriteLinkTargetType::DirectoryOrJunctionPoint,
1878                }
1879                .cell(),
1880            )
1881            .await?;
1882
1883            Ok(())
1884        }
1885
1886        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1887        async fn test_write_link() {
1888            let scratch = tempfile::tempdir().unwrap();
1889            let path = scratch.path().to_owned();
1890
1891            create_dir_all(path.join("subdir-a")).unwrap();
1892            File::create_new(path.join("subdir-a/data.txt"))
1893                .unwrap()
1894                .write_all(b"foo")
1895                .unwrap();
1896            create_dir_all(path.join("subdir-b")).unwrap();
1897            File::create_new(path.join("subdir-b/data.txt"))
1898                .unwrap()
1899                .write_all(b"bar")
1900                .unwrap();
1901            let root = path.to_str().unwrap().into();
1902
1903            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
1904                BackendOptions::default(),
1905                noop_backing_storage(),
1906            ));
1907
1908            tt.run_once(async move {
1909                let fs = disk_file_system_operation(root)
1910                    .resolve()
1911                    .strongly_consistent()
1912                    .await?;
1913                let root_path = disk_file_system_root(fs);
1914
1915                read_strongly_consistent_and_apply_effects(
1916                    extract_effects_operation(test_write_link_effect_operation(
1917                        fs,
1918                        root_path.clone(),
1919                        rcstr!("subdir-a"),
1920                    )),
1921                    |e| e,
1922                )
1923                .await?;
1924
1925                assert_eq!(read_to_string(path.join("symlink-file")).unwrap(), "foo");
1926                assert_eq!(
1927                    read_to_string(path.join("symlink-dir/data.txt")).unwrap(),
1928                    "foo"
1929                );
1930                #[cfg(not(windows))]
1931                assert_eq!(
1932                    std::fs::read_link(path.join("symlink-parent")).unwrap(),
1933                    std::path::PathBuf::from(".")
1934                );
1935
1936                // Write the same links again but with different targets
1937                read_strongly_consistent_and_apply_effects(
1938                    extract_effects_operation(test_write_link_effect_operation(
1939                        fs,
1940                        root_path,
1941                        rcstr!("subdir-b"),
1942                    )),
1943                    |e| e,
1944                )
1945                .await?;
1946
1947                assert_eq!(read_to_string(path.join("symlink-file")).unwrap(), "bar");
1948                assert_eq!(
1949                    read_to_string(path.join("symlink-dir/data.txt")).unwrap(),
1950                    "bar"
1951                );
1952
1953                anyhow::Ok(())
1954            })
1955            .await
1956            .unwrap();
1957        }
1958
1959        /// A relative symlink's `target` must be the raw link-relative value stored on disk
1960        /// (consumers like `realpath_with_links` and `write_link` resolve it against the
1961        /// directory *containing* the link). It must not be normalized or made root-relative.
1962        #[turbo_tasks::function(operation, root)]
1963        async fn assert_read_relative_symlink_operation(
1964            fs: ResolvedVc<DiskFileSystem>,
1965            root_path: FileSystemPath,
1966        ) -> anyhow::Result<()> {
1967            // sub/link-sibling -> foo.txt     (resolves to sub/foo.txt)
1968            let sibling = fs.read_link(root_path.join("sub/link-sibling")?).await?;
1969            assert_eq!(
1970                *sibling,
1971                LinkContent::Link {
1972                    target: LinkTarget::Relative {
1973                        raw: rcstr!("foo.txt"),
1974                        resolved: root_path.join("sub/foo.txt")?,
1975                    },
1976                }
1977            );
1978
1979            // sub/link-parent -> ../root.txt  (resolves to root.txt)
1980            let parent = fs.read_link(root_path.join("sub/link-parent")?).await?;
1981            assert_eq!(
1982                *parent,
1983                LinkContent::Link {
1984                    target: LinkTarget::Relative {
1985                        raw: rcstr!("../root.txt"),
1986                        resolved: root_path.join("root.txt")?,
1987                    },
1988                }
1989            );
1990
1991            Ok(())
1992        }
1993
1994        #[cfg(all(unix, debug_assertions))]
1995        #[turbo_tasks::function(operation, root)]
1996        async fn assert_read_realpath_operation(root_path: FileSystemPath) -> anyhow::Result<()> {
1997            let unresolved_dir = root_path.join("alias/child")?;
1998            let resolved_dir = unresolved_dir
1999                .realpath()
2000                .await?
2001                .expect("the linked directory should resolve");
2002
2003            assert_ne!(unresolved_dir, resolved_dir);
2004            let error = unresolved_dir
2005                .read_dir()
2006                .await
2007                .expect_err("a directory read through a symlinked parent must be rejected");
2008            let message = format!("{error:#}");
2009            assert!(message.contains("alias/child"));
2010            assert!(message.contains("real/child"));
2011            assert!(matches!(
2012                &*resolved_dir.read_dir().await?,
2013                DirectoryContent::Entries(entries) if entries.contains_key(&rcstr!("data.txt"))
2014            ));
2015
2016            assert!(matches!(
2017                &*root_path.join("file-alias")?.raw_read_dir().await?,
2018                RawDirectoryContent::NotFound
2019            ));
2020
2021            let unresolved_file = unresolved_dir.join("data.txt")?;
2022            let resolved_file = unresolved_file
2023                .realpath()
2024                .await?
2025                .expect("the linked file should resolve");
2026            assert_ne!(unresolved_file, resolved_file);
2027            let error = unresolved_file
2028                .read()
2029                .await
2030                .expect_err("a file read through a symlinked parent must be rejected");
2031            let message = format!("{error:#}");
2032            assert!(message.contains("alias/child/data.txt"));
2033            assert!(message.contains("real/child/data.txt"));
2034            assert!(matches!(
2035                &*resolved_file.read().await?,
2036                FileContent::Content(_)
2037            ));
2038
2039            Ok(())
2040        }
2041
2042        #[cfg(all(unix, debug_assertions))]
2043        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2044        async fn test_reads_require_realpath() {
2045            use std::os::unix::fs::symlink;
2046
2047            let scratch = tempfile::tempdir().unwrap();
2048            let path = scratch.path().to_owned();
2049            create_dir_all(path.join("real/child")).unwrap();
2050            File::create_new(path.join("real/child/data.txt")).unwrap();
2051            symlink("real", path.join("alias")).unwrap();
2052            symlink("real/child/data.txt", path.join("file-alias")).unwrap();
2053
2054            let root = canonicalize_to_rcstr(&path).unwrap();
2055            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2056                BackendOptions::default(),
2057                noop_backing_storage(),
2058            ));
2059
2060            tt.run_once(async move {
2061                let fs = disk_file_system_operation(root)
2062                    .resolve()
2063                    .strongly_consistent()
2064                    .await?;
2065                let root_path = disk_file_system_root(fs);
2066                assert_read_realpath_operation(root_path)
2067                    .read_strongly_consistent()
2068                    .await?;
2069                anyhow::Ok(())
2070            })
2071            .await
2072            .unwrap();
2073        }
2074
2075        /// `read_link` never looks at the target, so a dangling link still reads back as a valid
2076        /// [`LinkContent::Link`]. Resolving it reports the missing target.
2077        #[cfg(unix)]
2078        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2079        async fn test_dangling_symlink() {
2080            use std::os::unix::fs::symlink;
2081
2082            let scratch = tempfile::tempdir().unwrap();
2083            let path = scratch.path().to_owned();
2084            create_dir_all(path.join("sub")).unwrap();
2085            create_dir_all(path.join("target-dir")).unwrap();
2086            symlink("missing.txt", path.join("sub/link-dangling")).unwrap();
2087            symlink("link-dangling", path.join("sub/link-chain")).unwrap();
2088            symlink("../target-dir", path.join("sub/link-dir")).unwrap();
2089
2090            let root = canonicalize_to_rcstr(&path).unwrap();
2091
2092            #[turbo_tasks::function(operation, root)]
2093            async fn assert_operation(
2094                fs: ResolvedVc<DiskFileSystem>,
2095                root_path: FileSystemPath,
2096            ) -> anyhow::Result<()> {
2097                let link_path = root_path.join("sub/link-dangling")?;
2098
2099                // The link itself is perfectly valid; only its target is missing.
2100                let link = fs.read_link(link_path.clone()).await?;
2101                let LinkContent::Link { target } = &*link else {
2102                    anyhow::bail!("expected a valid link, got {link:?}");
2103                };
2104                assert_eq!(
2105                    *target,
2106                    LinkTarget::Relative {
2107                        raw: rcstr!("missing.txt"),
2108                        resolved: root_path.join("sub/missing.txt")?,
2109                    }
2110                );
2111                assert_eq!(target.target_type().await?, FileSystemEntryType::NotFound,);
2112
2113                // `realpath` follows the link and reports the missing target.
2114                let result = link_path.realpath_with_links().await?;
2115                assert!(matches!(
2116                    result.path_result.as_ref().unwrap_err().kind(),
2117                    RealPathErrorType::NotFound
2118                ));
2119
2120                // The same missing target after another link is also reported as not found.
2121                let chain_path = root_path.join("sub/link-chain")?;
2122                let result = chain_path.realpath_with_links().await?;
2123                assert!(matches!(
2124                    result.path_result.as_ref().unwrap_err().kind(),
2125                    RealPathErrorType::NotFound
2126                ));
2127
2128                // A missing path beneath a resolved directory link is reported as not found.
2129                let missing_in_linked_dir = root_path.join("sub/link-dir/package.json")?;
2130                let result = missing_in_linked_dir.realpath_with_links().await?;
2131                assert!(matches!(
2132                    result.path_result.as_ref().unwrap_err().kind(),
2133                    RealPathErrorType::NotFound
2134                ));
2135
2136                // A path that simply doesn't exist is also reported as not found.
2137                let missing = root_path.join("sub/missing.txt")?;
2138                let result = missing.realpath_with_links().await?;
2139                assert!(matches!(
2140                    result.path_result.as_ref().unwrap_err().kind(),
2141                    RealPathErrorType::NotFound
2142                ));
2143
2144                Ok(())
2145            }
2146
2147            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2148                BackendOptions::default(),
2149                noop_backing_storage(),
2150            ));
2151
2152            tt.run_once(async move {
2153                let fs = disk_file_system_operation(root)
2154                    .resolve()
2155                    .strongly_consistent()
2156                    .await?;
2157
2158                assert_operation(fs, disk_file_system_root(fs))
2159                    .read_strongly_consistent()
2160                    .await?;
2161
2162                anyhow::Ok(())
2163            })
2164            .await
2165            .unwrap();
2166
2167            tt.stop_and_wait().await;
2168        }
2169
2170        #[cfg(unix)]
2171        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2172        async fn test_link_target_resolved_type_through_chain() {
2173            use std::os::unix::fs::symlink;
2174
2175            let scratch = tempfile::tempdir().unwrap();
2176            let path = scratch.path().to_owned();
2177            create_dir_all(path.join("target-dir")).unwrap();
2178            File::create_new(path.join("target-file")).unwrap();
2179            symlink("target-dir", path.join("dir-inner")).unwrap();
2180            symlink("dir-inner", path.join("dir-outer")).unwrap();
2181            symlink("target-file", path.join("file-inner")).unwrap();
2182            symlink("file-inner", path.join("file-outer")).unwrap();
2183            symlink("../outside", path.join("invalid-inner")).unwrap();
2184            symlink("invalid-inner", path.join("invalid-outer")).unwrap();
2185
2186            let root = canonicalize_to_rcstr(&path).unwrap();
2187
2188            #[turbo_tasks::function(operation, root)]
2189            async fn assert_operation(
2190                fs: ResolvedVc<DiskFileSystem>,
2191                root_path: FileSystemPath,
2192            ) -> anyhow::Result<()> {
2193                for (input_path, expected_output) in [
2194                    ("dir-outer", FileSystemEntryType::Directory),
2195                    ("file-outer", FileSystemEntryType::File),
2196                    ("invalid-outer", FileSystemEntryType::Error),
2197                ] {
2198                    let link = fs.read_link(root_path.join(input_path)?).await?;
2199                    let LinkContent::Link { target } = &*link else {
2200                        anyhow::bail!("expected a valid link, got {link:?}");
2201                    };
2202                    assert_eq!(target.resolved_type().await?, expected_output);
2203                }
2204
2205                Ok(())
2206            }
2207
2208            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2209                BackendOptions::default(),
2210                noop_backing_storage(),
2211            ));
2212
2213            tt.run_once(async move {
2214                let fs = disk_file_system_operation(root)
2215                    .resolve()
2216                    .strongly_consistent()
2217                    .await?;
2218
2219                assert_operation(fs, disk_file_system_root(fs))
2220                    .read_strongly_consistent()
2221                    .await?;
2222
2223                anyhow::Ok(())
2224            })
2225            .await
2226            .unwrap();
2227
2228            tt.stop_and_wait().await;
2229        }
2230
2231        /// A relative target can step above the root and return to it, but a target that ends
2232        /// outside all configured roots is invalid.
2233        #[cfg(unix)]
2234        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2235        async fn test_read_escaping_relative_symlink() {
2236            use std::os::unix::fs::symlink;
2237
2238            let scratch = tempfile::tempdir().unwrap();
2239            // The fs root is a subdirectory, so that `../..` can step above it and back in.
2240            let path = scratch.path().join("the-root");
2241            create_dir_all(path.join("sub")).unwrap();
2242            File::create_new(path.join("root.txt"))
2243                .unwrap()
2244                .write_all(b"root")
2245                .unwrap();
2246            // Steps above the root and back down into it.
2247            symlink("../../the-root/root.txt", path.join("sub/link-reentrant")).unwrap();
2248            // Steps above the root and back down into a sibling of the root, so it escapes.
2249            create_dir_all(scratch.path().join("sibling")).unwrap();
2250            File::create_new(scratch.path().join("sibling/root.txt"))
2251                .unwrap()
2252                .write_all(b"sibling")
2253                .unwrap();
2254            symlink("../../sibling/root.txt", path.join("sub/link-sideways")).unwrap();
2255            // Stays inside the root the whole way.
2256            symlink("../root.txt", path.join("sub/link-inside")).unwrap();
2257            // A target naming a file that literally contains a backslash.
2258            File::create_new(path.join("sub/a\\b.txt"))
2259                .unwrap()
2260                .write_all(b"backslash")
2261                .unwrap();
2262            symlink("a\\b.txt", path.join("sub/link-backslash")).unwrap();
2263
2264            let root = canonicalize_to_rcstr(&path).unwrap();
2265
2266            #[turbo_tasks::function(operation, root)]
2267            async fn assert_operation(
2268                fs: ResolvedVc<DiskFileSystem>,
2269                root_path: FileSystemPath,
2270            ) -> anyhow::Result<()> {
2271                let reentrant = fs.read_link(root_path.join("sub/link-reentrant")?).await?;
2272                assert_eq!(
2273                    *reentrant,
2274                    LinkContent::Link {
2275                        target: LinkTarget::Relative {
2276                            raw: rcstr!("../../the-root/root.txt"),
2277                            resolved: root_path.join("root.txt")?,
2278                        },
2279                    }
2280                );
2281
2282                // sub/link-sideways -> ../../sibling/root.txt, which steps above the root and down
2283                // into a sibling, so it genuinely ends outside.
2284                let sideways = fs.read_link(root_path.join("sub/link-sideways")?).await?;
2285                assert!(matches!(
2286                    &*sideways,
2287                    LinkContent::Invalid{reason}
2288                        if reason == "the symlink target leaves the configured filesystem roots"
2289                ));
2290
2291                // `\` is a legal filename character on unix, so a raw target may contain one. It
2292                // must not be treated as a separator, and must not trip
2293                // `join_path`'s debug assertion.
2294                let backslash_path = root_path.join("sub/link-backslash")?;
2295                let backslash = fs.read_link(backslash_path.clone()).await?;
2296                assert_eq!(
2297                    *backslash,
2298                    LinkContent::Link {
2299                        target: LinkTarget::Relative {
2300                            raw: rcstr!("a\\b.txt"),
2301                            resolved: root_path.join("sub/a\\b.txt")?,
2302                        },
2303                    }
2304                );
2305
2306                // A relative target that stays within the root throughout is still fine.
2307                let inside_path = root_path.join("sub/link-inside")?;
2308                let inside = fs.read_link(inside_path.clone()).await?;
2309                let LinkContent::Link { target } = &*inside else {
2310                    anyhow::bail!("expected a valid link, got {inside:?}");
2311                };
2312                assert_eq!(
2313                    *target,
2314                    LinkTarget::Relative {
2315                        raw: rcstr!("../root.txt"),
2316                        resolved: root_path.join("root.txt")?,
2317                    }
2318                );
2319                assert_eq!(target.target_type().await?, FileSystemEntryType::File,);
2320
2321                Ok(())
2322            }
2323
2324            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2325                BackendOptions::default(),
2326                noop_backing_storage(),
2327            ));
2328            tt.run_once(async move {
2329                let fs = disk_file_system_operation(root)
2330                    .resolve()
2331                    .strongly_consistent()
2332                    .await?;
2333                let root_path = disk_file_system_root(fs);
2334
2335                assert_operation(fs, root_path)
2336                    .read_strongly_consistent()
2337                    .await?;
2338
2339                anyhow::Ok(())
2340            })
2341            .await
2342            .unwrap();
2343
2344            tt.stop_and_wait().await;
2345        }
2346
2347        #[cfg(unix)]
2348        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2349        async fn test_read_relative_symlink() {
2350            use std::os::unix::fs::symlink;
2351
2352            let scratch = tempfile::tempdir().unwrap();
2353            let path = scratch.path().to_owned();
2354
2355            // root.txt
2356            // sub/foo.txt
2357            // sub/link-sibling -> foo.txt
2358            // sub/link-parent  -> ../root.txt
2359            create_dir_all(path.join("sub")).unwrap();
2360            File::create_new(path.join("root.txt"))
2361                .unwrap()
2362                .write_all(b"root")
2363                .unwrap();
2364            File::create_new(path.join("sub/foo.txt"))
2365                .unwrap()
2366                .write_all(b"foo")
2367                .unwrap();
2368            symlink("foo.txt", path.join("sub/link-sibling")).unwrap();
2369            symlink("../root.txt", path.join("sub/link-parent")).unwrap();
2370
2371            let root: RcStr = path.to_str().unwrap().into();
2372
2373            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2374                BackendOptions::default(),
2375                noop_backing_storage(),
2376            ));
2377
2378            tt.run_once(async move {
2379                let fs = disk_file_system_operation(root)
2380                    .resolve()
2381                    .strongly_consistent()
2382                    .await?;
2383                let root_path = disk_file_system_root(fs);
2384
2385                assert_read_relative_symlink_operation(fs, root_path)
2386                    .read_strongly_consistent()
2387                    .await?;
2388
2389                anyhow::Ok(())
2390            })
2391            .await
2392            .unwrap();
2393        }
2394
2395        #[cfg(unix)]
2396        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2397        async fn test_read_absolute_symlink_slow_path() {
2398            use std::os::unix::fs::symlink;
2399
2400            let scratch = tempfile::tempdir().unwrap();
2401            let path = scratch.path().to_owned();
2402
2403            // real-root/foo.txt                     (the filesystem root's contents)
2404            // alias -> real-root                    (a differently-spelled path to the fs root)
2405            // outside.txt                           (outside of the fs root)
2406            // real-root/link-via-alias -> <scratch>/alias/foo.txt
2407            // real-root/link-outside   -> <scratch>/outside.txt
2408            let real_root = path.join("real-root");
2409            create_dir_all(&real_root).unwrap();
2410            File::create_new(real_root.join("foo.txt"))
2411                .unwrap()
2412                .write_all(b"foo")
2413                .unwrap();
2414            File::create_new(path.join("outside.txt"))
2415                .unwrap()
2416                .write_all(b"outside")
2417                .unwrap();
2418            symlink(&real_root, path.join("alias")).unwrap();
2419            symlink(path.join("alias/foo.txt"), real_root.join("link-via-alias")).unwrap();
2420            symlink(path.join("outside.txt"), real_root.join("link-outside")).unwrap();
2421
2422            // `DiskFileSystem::new` requires a canonicalized root. The raw targets above are
2423            // spelled via the un-canonicalized `scratch` path and the `alias` symlink, so they
2424            // never lexically match the root and must take the slow path.
2425            let root = canonicalize_to_rcstr(&real_root).unwrap();
2426
2427            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2428                BackendOptions::default(),
2429                noop_backing_storage(),
2430            ));
2431
2432            /// An absolute symlink target uses a symlinked alias of the root, so lexically
2433            /// stripping the prefix from the target does not work. `read_link`'s slow path must
2434            /// map it into the filesystem root, and must reject targets that are outside of the
2435            /// root.
2436            #[turbo_tasks::function(operation, root)]
2437            async fn assert_read_absolute_symlink_slow_path_operation(
2438                fs: ResolvedVc<DiskFileSystem>,
2439                root_path: FileSystemPath,
2440            ) -> anyhow::Result<()> {
2441                // link-via-alias -> <scratch>/alias/foo.txt  (resolves to <fs root>/foo.txt)
2442                let via_alias = fs.read_link(root_path.join("link-via-alias")?).await?;
2443                assert!(matches!(
2444                    &*via_alias,
2445                    LinkContent::Link {
2446                        target: LinkTarget::Absolute { resolved, .. },
2447                    } if resolved == &root_path.join("foo.txt")?
2448                ));
2449
2450                // link-outside -> <scratch>/outside.txt  (outside of the fs root)
2451                let outside = fs.read_link(root_path.join("link-outside")?).await?;
2452                assert!(matches!(
2453                    &*outside,
2454                    LinkContent::Invalid { reason}
2455                        if reason.contains("leaves the filesystem root")
2456                ));
2457
2458                Ok(())
2459            }
2460
2461            tt.run_once(async move {
2462                let fs = disk_file_system_operation(root)
2463                    .resolve()
2464                    .strongly_consistent()
2465                    .await?;
2466                let root_path = disk_file_system_root(fs);
2467
2468                assert_read_absolute_symlink_slow_path_operation(fs, root_path)
2469                    .read_strongly_consistent()
2470                    .await?;
2471
2472                anyhow::Ok(())
2473            })
2474            .await
2475            .unwrap();
2476        }
2477
2478        const STRESS_ITERATIONS: usize = 100;
2479        const STRESS_PARALLELISM: usize = 8;
2480        const STRESS_TARGET_COUNT: usize = 20;
2481        const STRESS_SYMLINK_COUNT: usize = 16;
2482
2483        #[turbo_tasks::function(operation, root)]
2484        fn disk_file_system_operation(fs_root: RcStr) -> Vc<DiskFileSystem> {
2485            DiskFileSystem::new(rcstr!("test"), Vc::cell(fs_root))
2486        }
2487
2488        fn disk_file_system_root(fs: ResolvedVc<DiskFileSystem>) -> FileSystemPath {
2489            FileSystemPath {
2490                fs: ResolvedVc::upcast(fs),
2491                path: rcstr!(""),
2492            }
2493        }
2494
2495        #[turbo_tasks::function(operation, root)]
2496        async fn write_symlink_stress_batch(
2497            fs: ResolvedVc<DiskFileSystem>,
2498            symlinks_dir: FileSystemPath,
2499            updates: Vec<(usize, usize)>,
2500        ) -> anyhow::Result<()> {
2501            use turbo_tasks::TryJoinIterExt;
2502
2503            updates
2504                .into_iter()
2505                .map(|(symlink_idx, target_idx)| {
2506                    let target = RcStr::from(format!("../_targets/{target_idx}"));
2507                    let symlink_path = symlinks_dir.join(&symlink_idx.to_string()).unwrap();
2508                    let target = symlinks_dir.join(&target).unwrap();
2509                    async move {
2510                        fs.write_link(
2511                            symlink_path,
2512                            WriteLinkContent {
2513                                target,
2514                                target_type: WriteLinkTargetType::DirectoryOrJunctionPoint,
2515                            }
2516                            .cell(),
2517                        )
2518                        .await
2519                    }
2520                })
2521                .try_join()
2522                .await?;
2523            Ok(())
2524        }
2525
2526        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2527        async fn test_symlink_stress() {
2528            let scratch = tempfile::tempdir().unwrap();
2529            let path = scratch.path().to_owned();
2530
2531            let targets_dir = path.join("_targets");
2532            create_dir_all(&targets_dir).unwrap();
2533            for i in 0..STRESS_TARGET_COUNT {
2534                create_dir_all(targets_dir.join(i.to_string())).unwrap();
2535            }
2536            create_dir_all(path.join("_symlinks")).unwrap();
2537
2538            let root = RcStr::from(path.to_str().unwrap());
2539
2540            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2541                BackendOptions::default(),
2542                noop_backing_storage(),
2543            ));
2544
2545            tt.run_once(async move {
2546                let fs = disk_file_system_operation(root)
2547                    .resolve()
2548                    .strongly_consistent()
2549                    .await?;
2550                let root_path = disk_file_system_root(fs);
2551                let symlinks_dir = root_path.join("_symlinks")?;
2552
2553                let initial_updates: Vec<(usize, usize)> =
2554                    (0..STRESS_SYMLINK_COUNT).map(|i| (i, 0)).collect();
2555                read_strongly_consistent_and_apply_effects(
2556                    extract_effects_operation(write_symlink_stress_batch(
2557                        fs,
2558                        symlinks_dir.clone(),
2559                        initial_updates,
2560                    )),
2561                    |e| e,
2562                )
2563                .await?;
2564
2565                let mut rng = rand::rngs::SmallRng::seed_from_u64(0);
2566                for _ in 0..STRESS_ITERATIONS {
2567                    let mut updates_map = rustc_hash::FxHashMap::default();
2568                    for _ in 0..STRESS_PARALLELISM {
2569                        let symlink_idx = rng.random_range(0..STRESS_SYMLINK_COUNT);
2570                        let target_idx = rng.random_range(0..STRESS_TARGET_COUNT);
2571                        updates_map.insert(symlink_idx, target_idx);
2572                    }
2573                    let updates: Vec<(usize, usize)> = updates_map.into_iter().collect();
2574
2575                    read_strongly_consistent_and_apply_effects(
2576                        extract_effects_operation(write_symlink_stress_batch(
2577                            fs,
2578                            symlinks_dir.clone(),
2579                            updates,
2580                        )),
2581                        |e| e,
2582                    )
2583                    .await?;
2584                }
2585
2586                anyhow::Ok(())
2587            })
2588            .await
2589            .unwrap();
2590
2591            tt.stop_and_wait().await;
2592        }
2593    }
2594
2595    // Tests helpers for denied_path tests
2596    #[cfg(test)]
2597    mod denied_path_tests {
2598        use std::{
2599            fs::{File, create_dir_all, read_to_string},
2600            io::Write,
2601            path::Path,
2602        };
2603
2604        use turbo_rcstr::{RcStr, rcstr};
2605        use turbo_tasks::{Effects, Vc, read_strongly_consistent_and_apply_effects, take_effects};
2606        use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
2607
2608        use crate::{
2609            DirectoryContent, DiskFileSystem, DiskFileSystemMap, DiskWatcherConfig,
2610            File as TurboFile, FileContent, FileSystem, FileSystemPath,
2611            glob::{Glob, GlobOptions},
2612        };
2613
2614        /// Helper to set up a test filesystem with denied_path
2615        /// Creates the filesystem structure on disk and returns paths
2616        fn setup_test_fs() -> (tempfile::TempDir, RcStr, RcStr) {
2617            let scratch = tempfile::tempdir().unwrap();
2618            let path = scratch.path();
2619
2620            // Create standard test structure:
2621            // /allowed_file.txt
2622            // /allowed_dir/file.txt
2623            // /other_file.txt
2624            // /denied_dir/secret.txt
2625            // /denied_dir/nested/deep.txt
2626            File::create_new(path.join("allowed_file.txt"))
2627                .unwrap()
2628                .write_all(b"allowed content")
2629                .unwrap();
2630
2631            create_dir_all(path.join("allowed_dir")).unwrap();
2632            File::create_new(path.join("allowed_dir/file.txt"))
2633                .unwrap()
2634                .write_all(b"allowed dir content")
2635                .unwrap();
2636
2637            File::create_new(path.join("other_file.txt"))
2638                .unwrap()
2639                .write_all(b"other content")
2640                .unwrap();
2641
2642            create_dir_all(path.join("denied_dir/nested")).unwrap();
2643            File::create_new(path.join("denied_dir/secret.txt"))
2644                .unwrap()
2645                .write_all(b"secret content")
2646                .unwrap();
2647            File::create_new(path.join("denied_dir/nested/deep.txt"))
2648                .unwrap()
2649                .write_all(b"deep secret")
2650                .unwrap();
2651
2652            let root = RcStr::from(path.to_str().unwrap());
2653            // denied_path should be relative to root, using unix separators
2654            let denied_path = rcstr!("denied_dir");
2655
2656            (scratch, root, denied_path)
2657        }
2658
2659        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2660        async fn test_denied_path_read() {
2661            #[turbo_tasks::function(operation, root)]
2662            async fn test_operation(root: RcStr, denied_path: RcStr) -> anyhow::Result<()> {
2663                let fs = DiskFileSystem::new_with_options(
2664                    rcstr!("test"),
2665                    Vc::cell(root),
2666                    vec![denied_path],
2667                    DiskWatcherConfig::default(),
2668                    DiskFileSystemMap::empty(),
2669                );
2670                let root_path = fs.root().await?;
2671
2672                // Test 1: Reading allowed file should work
2673                let allowed_file = root_path.join("allowed_file.txt")?;
2674                let content = allowed_file.read().await?;
2675                assert!(
2676                    matches!(&*content, FileContent::Content(_)),
2677                    "allowed file should be readable"
2678                );
2679
2680                // Test 2: Direct read of denied file should return NotFound
2681                let denied_file = root_path.join("denied_dir/secret.txt")?;
2682                let content = denied_file.read().await?;
2683                assert!(
2684                    matches!(&*content, FileContent::NotFound),
2685                    "denied file should return NotFound, got {:?}",
2686                    content
2687                );
2688
2689                // Test 3: Reading nested denied file should return NotFound
2690                let nested_denied = root_path.join("denied_dir/nested/deep.txt")?;
2691                let content = nested_denied.read().await?;
2692                assert!(
2693                    matches!(&*content, FileContent::NotFound),
2694                    "nested denied file should return NotFound"
2695                );
2696
2697                // Test 4: Reading the denied directory itself should return NotFound
2698                let denied_dir = root_path.join("denied_dir")?;
2699                let content = denied_dir.read().await?;
2700                assert!(
2701                    matches!(&*content, FileContent::NotFound),
2702                    "denied directory should return NotFound"
2703                );
2704
2705                Ok(())
2706            }
2707
2708            let (_scratch, root, denied_path) = setup_test_fs();
2709            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2710                BackendOptions::default(),
2711                noop_backing_storage(),
2712            ));
2713            tt.run_once(async {
2714                test_operation(root, denied_path)
2715                    .read_strongly_consistent()
2716                    .await?;
2717
2718                anyhow::Ok(())
2719            })
2720            .await
2721            .unwrap();
2722        }
2723
2724        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2725        async fn test_denied_path_read_dir() {
2726            #[turbo_tasks::function(operation, root)]
2727            async fn test_operation(root: RcStr, denied_path: RcStr) -> anyhow::Result<()> {
2728                let fs = DiskFileSystem::new_with_options(
2729                    rcstr!("test"),
2730                    Vc::cell(root),
2731                    vec![denied_path],
2732                    DiskWatcherConfig::default(),
2733                    DiskFileSystemMap::empty(),
2734                );
2735                let root_path = fs.root().await?;
2736
2737                // Test: read_dir on root should not include denied_dir
2738                let dir_content = root_path.read_dir().await?;
2739                match &*dir_content {
2740                    DirectoryContent::Entries(entries) => {
2741                        assert!(
2742                            entries.contains_key(&rcstr!("allowed_dir")),
2743                            "allowed_dir should be visible"
2744                        );
2745                        assert!(
2746                            entries.contains_key(&rcstr!("other_file.txt")),
2747                            "other_file.txt should be visible"
2748                        );
2749                        assert!(
2750                            entries.contains_key(&rcstr!("allowed_file.txt")),
2751                            "allowed_file.txt should be visible"
2752                        );
2753                        assert!(
2754                            !entries.contains_key(&rcstr!("denied_dir")),
2755                            "denied_dir should NOT be visible in read_dir"
2756                        );
2757                    }
2758                    DirectoryContent::NotFound => panic!("root directory should exist"),
2759                }
2760
2761                // Test: read_dir on denied_dir should return NotFound
2762                let denied_dir = root_path.join("denied_dir")?;
2763                let dir_content = denied_dir.read_dir().await?;
2764                assert!(
2765                    matches!(&*dir_content, DirectoryContent::NotFound),
2766                    "denied_dir read_dir should return NotFound"
2767                );
2768
2769                Ok(())
2770            }
2771
2772            let (_scratch, root, denied_path) = setup_test_fs();
2773            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2774                BackendOptions::default(),
2775                noop_backing_storage(),
2776            ));
2777            tt.run_once(async {
2778                test_operation(root, denied_path)
2779                    .read_strongly_consistent()
2780                    .await?;
2781
2782                anyhow::Ok(())
2783            })
2784            .await
2785            .unwrap();
2786        }
2787
2788        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2789        async fn test_denied_path_read_glob() {
2790            #[turbo_tasks::function(operation, root)]
2791            async fn test_operation(root: RcStr, denied_path: RcStr) -> anyhow::Result<()> {
2792                let fs = DiskFileSystem::new_with_options(
2793                    rcstr!("test"),
2794                    Vc::cell(root),
2795                    vec![denied_path],
2796                    DiskWatcherConfig::default(),
2797                    DiskFileSystemMap::empty(),
2798                );
2799                let root_path = fs.root().await?;
2800
2801                // Test: read_glob with ** should not reveal denied files
2802                let glob_result = root_path
2803                    .read_glob(Glob::new(rcstr!("**/*.txt"), GlobOptions::default()))
2804                    .await?;
2805
2806                // Check top level results
2807                assert!(
2808                    glob_result.results.contains_key("allowed_file.txt"),
2809                    "allowed_file.txt should be found"
2810                );
2811                assert!(
2812                    glob_result.results.contains_key("other_file.txt"),
2813                    "other_file.txt should be found"
2814                );
2815                assert!(
2816                    !glob_result.results.contains_key("denied_dir"),
2817                    "denied_dir should NOT appear in glob results"
2818                );
2819
2820                // Check that denied_dir doesn't appear in inner results
2821                assert!(
2822                    !glob_result.inner.contains_key("denied_dir"),
2823                    "denied_dir should NOT appear in glob inner results"
2824                );
2825
2826                // Verify allowed_dir is present (to ensure we're not filtering everything)
2827                assert!(
2828                    glob_result.inner.contains_key("allowed_dir"),
2829                    "allowed_dir directory should be present"
2830                );
2831                let sub_inner = glob_result.inner.get("allowed_dir").unwrap().await?;
2832                assert!(
2833                    sub_inner.results.contains_key("file.txt"),
2834                    "allowed_dir/file.txt should be found"
2835                );
2836
2837                Ok(())
2838            }
2839
2840            let (_scratch, root, denied_path) = setup_test_fs();
2841            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2842                BackendOptions::default(),
2843                noop_backing_storage(),
2844            ));
2845            tt.run_once(async {
2846                test_operation(root, denied_path)
2847                    .read_strongly_consistent()
2848                    .await?;
2849
2850                anyhow::Ok(())
2851            })
2852            .await
2853            .unwrap();
2854        }
2855
2856        #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2857        async fn test_denied_path_write() {
2858            #[turbo_tasks::function(operation, root)]
2859            async fn write_file_operation(
2860                path: FileSystemPath,
2861                contents: RcStr,
2862            ) -> anyhow::Result<()> {
2863                path.write(
2864                    FileContent::Content(TurboFile::from_bytes(contents.to_string().into_bytes()))
2865                        .cell(),
2866                )
2867                .await?;
2868                Ok(())
2869            }
2870
2871            /// Writes the allowed file and captures effects to be applied at
2872            /// the top level.
2873            #[turbo_tasks::function(operation, root)]
2874            async fn write_allowed_file_operation(
2875                root: RcStr,
2876                denied_path: RcStr,
2877                file_path: RcStr,
2878                contents: RcStr,
2879            ) -> anyhow::Result<Vc<Effects>> {
2880                let fs = DiskFileSystem::new_with_options(
2881                    rcstr!("test"),
2882                    Vc::cell(root),
2883                    vec![denied_path],
2884                    DiskWatcherConfig::default(),
2885                    DiskFileSystemMap::empty(),
2886                );
2887                let root_path = fs.root().await?;
2888                let allowed_file = root_path.join(&file_path)?;
2889                let write_op = write_file_operation(allowed_file, contents);
2890                write_op.read_strongly_consistent().await?;
2891                Ok(take_effects(write_op).await?.cell())
2892            }
2893
2894            #[turbo_tasks::function(operation, root)]
2895            async fn test_denied_writes_operation(
2896                root: RcStr,
2897                denied_path: RcStr,
2898                denied_file: RcStr,
2899                nested_denied_file: RcStr,
2900            ) -> anyhow::Result<()> {
2901                let fs = DiskFileSystem::new_with_options(
2902                    rcstr!("test"),
2903                    Vc::cell(root),
2904                    vec![denied_path],
2905                    DiskWatcherConfig::default(),
2906                    DiskFileSystemMap::empty(),
2907                );
2908                let root_path = fs.root().await?;
2909
2910                let path = root_path.join(&denied_file)?;
2911                let result = write_file_operation(path, rcstr!("forbidden"))
2912                    .read_strongly_consistent()
2913                    .await;
2914                assert!(
2915                    result.is_err(),
2916                    "writing to denied path should return an error"
2917                );
2918
2919                let path = root_path.join(&nested_denied_file)?;
2920                let result = write_file_operation(path, rcstr!("nested"))
2921                    .read_strongly_consistent()
2922                    .await;
2923                assert!(
2924                    result.is_err(),
2925                    "writing to nested denied path should return an error"
2926                );
2927
2928                Ok(())
2929            }
2930
2931            let (_scratch, root, denied_path) = setup_test_fs();
2932            let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
2933                BackendOptions::default(),
2934                noop_backing_storage(),
2935            ));
2936            tt.run_once(async {
2937                const ALLOWED_FILE: &str = "allowed_dir/new_file.txt";
2938                const TEST_CONTENT: &str = "test content";
2939
2940                // Test 1: Writing to allowed directory should work
2941                let effects_op = write_allowed_file_operation(
2942                    root.clone(),
2943                    denied_path.clone(),
2944                    RcStr::from(ALLOWED_FILE),
2945                    RcStr::from(TEST_CONTENT),
2946                );
2947                read_strongly_consistent_and_apply_effects(effects_op, |e| e).await?;
2948
2949                // Verify the file was written to disk
2950                let content = read_to_string(Path::new(root.as_str()).join(ALLOWED_FILE))?;
2951                assert_eq!(content, TEST_CONTENT, "allowed file write should succeed");
2952
2953                // Tests 2 & 3: Writing to denied paths should fail
2954                test_denied_writes_operation(
2955                    root,
2956                    denied_path,
2957                    RcStr::from("denied_dir/forbidden.txt"),
2958                    RcStr::from("denied_dir/nested/file.txt"),
2959                )
2960                .read_strongly_consistent()
2961                .await?;
2962
2963                anyhow::Ok(())
2964            })
2965            .await
2966            .unwrap();
2967        }
2968    }
2969}