Repository: vercel/next.js
Pull request: feat(turbo-tasks-fs): support multi denied_paths

 Support multi denied_paths in `DiskFileSystem`, it's useful to reduce redundant task invalidation.

Changed files:
filename: crates/next-api/src/project.rs
patch: @@ -166,8 +166,8 @@ pub struct ProjectOptions {
     /// E.g. `/home/user/projects/my-repo`.
     pub root_path: RcStr,
 
-    /// A path which contains the app/pages directories, relative to [`Project::root_path`], always
-    /// Unix path. E.g. `apps/my-app`
+    /// A path which contains the app/pages directories, relative to [`Project::project_path`],
+    /// always Unix path. E.g. `apps/my-app`
     pub project_path: RcStr,
 
     /// The contents of next.config.js, serialized to JSON.
@@ -817,10 +817,10 @@ impl Project {
             }
         };
 
-        Ok(DiskFileSystem::new_with_denied_path(
+        Ok(DiskFileSystem::new_with_denied_paths(
             rcstr!(PROJECT_FILESYSTEM_NAME),
             self.root_path.clone(),
-            denied_path,
+            vec![denied_path],
         ))
     }
 

filename: turbopack/crates/turbo-tasks-fs/src/lib.rs
patch: @@ -285,9 +285,9 @@ struct DiskFileSystemInner {
 
     #[turbo_tasks(debug_ignore, trace_ignore)]
     watcher: DiskWatcher,
-    /// A root path that we do not allow access to from this filesystem.
+    /// Root paths that we do not allow access to from this filesystem.
     /// Useful for things like output directories to prevent accidental ouroboros situations.
-    denied_path: Option<RcStr>,
+    denied_paths: Vec<RcStr>,
 }
 
 impl DiskFileSystemInner {
@@ -300,24 +300,19 @@ impl DiskFileSystemInner {
     /// Checks if a path is within the denied path
     /// Returns true if the path should be treated as non-existent
     ///
-    /// Since denied_path is guaranteed to be:
+    /// Since denied_paths are guaranteed to be:
     /// - normalized (no ../ traversals)
     /// - using unix separators (/)
     /// - relative to the fs root
     ///
     /// We can efficiently check using string operations
     fn is_path_denied(&self, path: &FileSystemPath) -> bool {
-        let Some(denied_path) = &self.denied_path else {
-            return false;
-        };
-        // If the path starts with the denied path then there are three cases
-        // * they are equal => denied
-        // * root relative path is a descendant which means the next character is a / => denied
-        // * anything else => not denied (covers denying `.next` but allowing `.next2`)
         let path = &path.path;
-        path.starts_with(denied_path.as_str())
-            && (path.len() == denied_path.len()
-                || path.as_bytes().get(denied_path.len()) == Some(&b'/'))
+        self.denied_paths.iter().any(|denied_path| {
+            path.starts_with(denied_path.as_str())
+                && (path.len() == denied_path.len()
+                    || path.as_bytes().get(denied_path.len()) == Some(&b'/'))
+        })
     }
 
     /// registers the path as an invalidator for the current task,
@@ -612,7 +607,7 @@ impl DiskFileSystem {
     /// * `root` - Path to the given filesystem's root. Should be
     ///   [canonicalized][std::fs::canonicalize].
     pub fn new(name: RcStr, root: RcStr) -> Vc<Self> {
-        Self::new_internal(name, root, None)
+        Self::new_internal(name, root, Vec::new())
     }
 
     /// Create a new instance of `DiskFileSystem`.
@@ -621,22 +616,24 @@ impl DiskFileSystem {
     /// * `name` - Name of the filesystem.
     /// * `root` - Path to the given filesystem's root. Should be
     ///   [canonicalized][std::fs::canonicalize].
-    /// * `denied_path` - A path within this filesystem that is not allowed to be accessed or
-    ///   navigated into.  This must be normalized, non-empty and relative to the fs root.
-    pub fn new_with_denied_path(name: RcStr, root: RcStr, denied_path: RcStr) -> Vc<Self> {
-        debug_assert!(!denied_path.is_empty(), "denied_path must not be empty");
-        debug_assert!(
-            normalize_path(&denied_path).as_deref() == Some(&*denied_path),
-            "denied_path must be normalized: {denied_path:?}"
-        );
-        Self::new_internal(name, root, Some(denied_path))
+    /// * `denied_paths` - Paths within this filesystem that are not allowed to be accessed or
+    ///   navigated into.  These must be normalized, non-empty and relative to the fs root.
+    pub fn new_with_denied_paths(name: RcStr, root: RcStr, denied_paths: Vec<RcStr>) -> Vc<Self> {
+        for denied_path in &denied_paths {
+            debug_assert!(!denied_path.is_empty(), "denied_path must not be empty");
+            debug_assert!(
+                normalize_path(denied_path).as_deref() == Some(&**denied_path),
+                "denied_path must be normalized: {denied_path:?}"
+            );
+        }
+        Self::new_internal(name, root, denied_paths)
     }
 }
 
 #[turbo_tasks::value_impl]
 impl DiskFileSystem {
     #[turbo_tasks::function]
-    fn new_internal(name: RcStr, root: RcStr, denied_path: Option<RcStr>) -> Vc<Self> {
+    fn new_internal(name: RcStr, root: RcStr, denied_paths: Vec<RcStr>) -> Vc<Self> {
         mark_stateful();
 
         let instance = DiskFileSystem {
@@ -650,7 +647,7 @@ impl DiskFileSystem {
                 read_semaphore: create_read_semaphore(),
                 write_semaphore: create_write_semaphore(),
                 watcher: DiskWatcher::new(),
-                denied_path,
+                denied_paths,
             }),
         };
 
@@ -732,15 +729,18 @@ impl FileSystem for DiskFileSystem {
                 bail!(anyhow!(e).context(format!("reading dir {}", full_path.display())))
             }
         };
-        let denied_entry = match self.inner.denied_path.as_ref() {
-            Some(denied_path) => {
+        let dir_path = fs_path.path.as_str();
+        let denied_entries: FxHashSet<&str> = self
+            .inner
+            .denied_paths
+            .iter()
+            .filter_map(|denied_path| {
                 // If we have a denied path, we need to see if the current directory is a prefix of
                 // the denied path meaning that it is possible that some directory entry needs to be
                 // filtered. we do this first to avoid string manipulation on every
                 // iteration of the directory entries. So while expanding `foo/bar`,
                 // if `foo/bar/baz` is denied, we filter out `baz`.
                 // But if foo/bar/baz/qux is denied we don't filter anything from this level.
-                let dir_path = fs_path.path.as_str();
                 if denied_path.starts_with(dir_path) {
                     let denied_path_suffix =
                         if denied_path.as_bytes().get(dir_path.len()) == Some(&b'/') {
@@ -755,9 +755,8 @@ impl FileSystem for DiskFileSystem {
                 } else {
                     None
                 }
-            }
-            None => None,
-        };
+            })
+            .collect();
 
         let entries = read_dir
             .filter_map(|r| {
@@ -769,9 +768,7 @@ impl FileSystem for DiskFileSystem {
                 // we filter out any non unicode names
                 let file_name: RcStr = e.file_name().to_str()?.into();
                 // Filter out denied entries
-                if let Some(denied_name) = denied_entry
-                    && denied_name == file_name.as_str()
-                {
+                if denied_entries.contains(file_name.as_str()) {
                     return None;
                 }
 
@@ -3118,7 +3115,8 @@ mod tests {
             ));
 
             tt.run_once(async {
-                let fs = DiskFileSystem::new_with_denied_path(rcstr!("test"), root, denied_path);
+                let fs =
+                    DiskFileSystem::new_with_denied_paths(rcstr!("test"), root, vec![denied_path]);
                 let root_path = fs.root().await?;
 
                 // Test 1: Reading allowed file should work
@@ -3169,7 +3167,8 @@ mod tests {
             ));
 
             tt.run_once(async {
-                let fs = DiskFileSystem::new_with_denied_path(rcstr!("test"), root, denied_path);
+                let fs =
+                    DiskFileSystem::new_with_denied_paths(rcstr!("test"), root, vec![denied_path]);
                 let root_path = fs.root().await?;
 
                 // Test: read_dir on root should not include denied_dir
@@ -3219,7 +3218,8 @@ mod tests {
             ));
 
             tt.run_once(async {
-                let fs = DiskFileSystem::new_with_denied_path(rcstr!("test"), root, denied_path);
+                let fs =
+                    DiskFileSystem::new_with_denied_paths(rcstr!("test"), root, vec![denied_path]);
                 let root_path = fs.root().await?;
 
                 // Test: read_glob with ** should not reveal denied files
@@ -3285,7 +3285,8 @@ mod tests {
             ));
 
             tt.run_once(async {
-                let fs = DiskFileSystem::new_with_denied_path(rcstr!("test"), root, denied_path);
+                let fs =
+                    DiskFileSystem::new_with_denied_paths(rcstr!("test"), root, vec![denied_path]);
                 let root_path = fs.root().await?;
 
                 // Test 1: Writing to allowed directory should work

filename: turbopack/crates/turbopack-cli/src/util.rs
patch: @@ -65,8 +65,11 @@ pub async fn project_fs(
     watch: bool,
     denied_root_path: RcStr,
 ) -> Result<Vc<Box<dyn FileSystem>>> {
-    let disk_fs =
-        DiskFileSystem::new_with_denied_path(rcstr!("project"), project_dir, denied_root_path);
+    let disk_fs = DiskFileSystem::new_with_denied_paths(
+        rcstr!("project"),
+        project_dir,
+        vec![denied_root_path],
+    );
     if watch {
         disk_fs.await?.start_watching(None).await?;
     }