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