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