next.js/crates/next-api/src/nft_json.rs
nft_json.rs894 lines32.5 KB
use std::collections::{BTreeSet, HashSet, VecDeque};

use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::json;
use tracing::{Instrument, Level, Span};
use turbo_rcstr::{RcStr, rcstr};
use turbo_tasks::{
    FxIndexMap, ReadRef, ResolvedVc, TryFlatJoinIterExt, TryJoinIterExt, ValueToString, Vc,
    graph::{AdjacencyMap, GraphTraversal, Visit},
    turbofmt,
};
use turbo_tasks_fs::{
    DirectoryEntry, File, FileContent, FileSystem, FileSystemPath,
    glob::{Glob, GlobOptions},
};
use turbopack_core::{
    asset::{Asset, AssetContent},
    issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString},
    output::{OutputAsset, OutputAssets, OutputAssetsReference},
};

use crate::project::Project;

/// A json file that produces references to all files that are needed by the given module
/// at runtime. This will include, for example, node native modules, unanalyzable packages,
/// client side chunks, etc.
///
/// With this file, users can determine the minimum set of files that are needed alongside
/// their bundle.
#[turbo_tasks::value]
pub struct NftJsonAsset {
    project: ResolvedVc<Project>,
    /// The chunk for which the asset is being generated
    chunk: ResolvedVc<Box<dyn OutputAsset>>,
    /// Additional assets to include in the nft json. This can be used to manually collect assets
    /// that are known to be required but are not in the graph yet, for whatever reason.
    ///
    /// An example of this is the two-phase approach used by the `ClientReferenceManifest` in
    /// next.js.
    additional_assets: Vec<ResolvedVc<Box<dyn OutputAsset>>>,
    // The page name, e.g. `pages/index` or `app/route1`
    page_name: Option<String>,
}

#[turbo_tasks::value_impl]
impl NftJsonAsset {
    #[turbo_tasks::function]
    pub fn new(
        project: ResolvedVc<Project>,
        page_name: Option<RcStr>,
        chunk: ResolvedVc<Box<dyn OutputAsset>>,
        additional_assets: Vec<ResolvedVc<Box<dyn OutputAsset>>>,
    ) -> Vc<Self> {
        NftJsonAsset {
            chunk,
            project,
            additional_assets,
            page_name: page_name.map(|page_name| format!("/{page_name}")),
        }
        .cell()
    }
}

#[turbo_tasks::value_impl]
impl OutputAssetsReference for NftJsonAsset {}

#[turbo_tasks::value_impl]
impl OutputAsset for NftJsonAsset {
    #[turbo_tasks::function]
    async fn path(&self) -> Result<Vc<FileSystemPath>> {
        let path = self.chunk.path().await?;
        Ok(path
            .fs
            .root()
            .await?
            .join(&format!("{}.nft.json", path.path))?
            .cell())
    }
}

fn get_output_specifier(
    path_ref: &FileSystemPath,
    ident_folder: &FileSystemPath,
    ident_folder_in_project_fs: &FileSystemPath,
    output_root: &FileSystemPath,
    project_root: &FileSystemPath,
) -> Result<RcStr> {
    // include assets in the outputs such as referenced chunks
    if path_ref.is_inside_ref(output_root) {
        return Ok(ident_folder.get_relative_path_to(path_ref).unwrap());
    }

    // include assets in the project root such as images and traced references (externals)
    if path_ref.is_inside_ref(project_root) {
        return Ok(ident_folder_in_project_fs
            .get_relative_path_to(path_ref)
            .unwrap());
    }
    // This should effectively be unreachable
    bail!("NftJsonAsset: cannot handle filepath '{path_ref}'");
}

/// Apply outputFileTracingIncludes patterns to find additional files
async fn apply_includes(
    project_root_path: FileSystemPath,
    glob: Vc<Glob>,
    ident_folder: &FileSystemPath,
) -> Result<BTreeSet<RcStr>> {
    debug_assert_eq!(project_root_path.fs, ident_folder.fs);
    // Read files matching the glob pattern from the project root
    // This result itself has random order, but the BTreeSet will ensure a deterministic ordering.
    let glob_result = project_root_path.read_glob(glob).await?;

    // Walk the full glob_result using an explicit stack to avoid async recursion overheads.
    let mut result = BTreeSet::new();
    let mut stack = VecDeque::new();
    stack.push_back(glob_result);
    while let Some(glob_result) = stack.pop_back() {
        // Process direct results (files and directories at this level)
        for entry in glob_result.results.values() {
            let (DirectoryEntry::File(file_path) | DirectoryEntry::Symlink(file_path)) = entry
            else {
                continue;
            };

            // Convert to relative path from ident_folder to the file
            // unwrap is safe because project_root_path and ident_folder have the same filesystem
            // and paths produced by read_glob stay in the filesystem
            let relative_path = ident_folder.get_relative_path_to(file_path).unwrap();
            result.insert(relative_path);
        }

        for nested_result in glob_result.inner.values() {
            let nested_result_ref = nested_result.await?;
            stack.push_back(nested_result_ref);
        }
    }
    Ok(result)
}

#[turbo_tasks::value_impl]
impl Asset for NftJsonAsset {
    #[turbo_tasks::function]
    async fn content(self: Vc<Self>) -> Result<Vc<AssetContent>> {
        let this = &*self.await?;
        let span = tracing::info_span!(
            "output file tracing",
            path = display(self.path().to_string().await?)
        );
        async move {
            let mut result: BTreeSet<RcStr> = BTreeSet::new();
            let project_path = this.project.project_path().owned().await?;

            let output_root_ref = this.project.output_fs().root().await?;
            let project_root_ref = this.project.project_fs().root().await?;
            let next_config = this.project.next_config();
            let next_config_path = this
                .project
                .next_config()
                .config_file_path(project_path.clone())
                .await?;

            let output_file_tracing_includes = &*next_config.output_file_tracing_includes().await?;
            let output_file_tracing_excludes = &*next_config.output_file_tracing_excludes().await?;

            let client_root = this.project.client_fs().root();
            let client_root = client_root.owned().await?;

            // [project]/
            let project_root_path = this.project.project_root_path().owned().await?;
            // Example: [output]/apps/my-website/.next/server/app -- without the `page.js.nft.json`
            let ident_folder = self.path().await?.parent();
            // Example: [project]/apps/my-website/.next/server/app -- without the `page.js.nft.json`
            let ident_folder_in_project_fs = project_root_path.join(&ident_folder.path)?;

            let chunk = this.chunk;
            let entries = this
                .additional_assets
                .iter()
                .copied()
                .chain(std::iter::once(chunk))
                .collect();

            let exclude_glob = if let Some(route) = &this.page_name {
                if let Some(excludes_config) = output_file_tracing_excludes {
                    let mut combined_excludes = BTreeSet::new();

                    if let Some(excludes_obj) = excludes_config.as_object() {
                        for (glob_pattern, exclude_patterns) in excludes_obj {
                            // Check if the route matches the glob pattern
                            let glob = Glob::new(
                                RcStr::from(glob_pattern.clone()),
                                GlobOptions { contains: true },
                            )
                            .await?;
                            if glob.matches(route)
                                && let Some(patterns) = exclude_patterns.as_array()
                            {
                                for pattern in patterns {
                                    if let Some(pattern_str) = pattern.as_str() {
                                        let (glob, root) =
                                            relativize_glob(pattern_str, project_path.clone())?;
                                        let glob = if root.path.is_empty() {
                                            glob.to_string()
                                        } else {
                                            format!("{root}/{glob}")
                                        };
                                        combined_excludes.insert(glob);
                                    }
                                }
                            }
                        }
                    }

                    if combined_excludes.is_empty() {
                        None
                    } else {
                        let glob = Glob::new(
                            format!(
                                "{{{}}}",
                                combined_excludes
                                    .iter()
                                    .map(|s| s.as_str())
                                    .collect::<Vec<_>>()
                                    .join(",")
                            )
                            .into(),
                            GlobOptions { contains: true },
                        );

                        Some(glob)
                    }
                } else {
                    None
                }
            } else {
                None
            };

            let entries = Vc::cell(entries);
            // Collect base assets first
            let all_assets =
                all_assets_from_entries_filtered(entries, Some(client_root.clone()), exclude_glob)
                    .await?;

            for referenced_chunk in all_assets.iter().copied() {
                if chunk.eq(&referenced_chunk) {
                    continue;
                }

                let referenced_chunk_path = referenced_chunk.path().await?;

                if referenced_chunk_path == next_config_path {
                    // If next.config.js was traced, assume that the whole project was traced
                    // (unintentionally). Print a message in this case to avoid deploying
                    // unnecessary files.
                    error_unexpected_file(
                        entries,
                        Some(client_root.clone()),
                        exclude_glob,
                        *referenced_chunk,
                    )
                    .await?;
                }

                if referenced_chunk_path.has_extension(".map") {
                    continue;
                }

                #[cfg(debug_assertions)]
                {
                    // Verify that we there are no entries where a file is created inside of a
                    // symlink, as this can result in invalid ZIP files and
                    // deployment failures. For example
                    // node_modules/.pnpm/node_modules/@libsql/client/package.json
                    // where
                    // node_modules/.pnpm/node_modules/@libsql/client is a symlink
                    let mut current_path = referenced_chunk_path.parent();
                    loop {
                        use turbo_tasks_fs::FileSystemEntryType;

                        if current_path.is_root() {
                            break;
                        }

                        if matches!(
                            &*current_path.get_type().await?,
                            FileSystemEntryType::Symlink
                        ) {
                            turbo_tasks::turbobail!(
                                "Encountered file inside of symlink in NFT list: {current_path} \
                                 is a symlink, but {referenced_chunk_path} was created inside of \
                                 it"
                            );
                        }

                        current_path = current_path.parent();
                    }
                }

                let specifier = match get_output_specifier(
                    &referenced_chunk_path,
                    &ident_folder,
                    &ident_folder_in_project_fs,
                    &output_root_ref,
                    &project_root_ref,
                ) {
                    Ok(specifier) => specifier,
                    Err(err) => {
                        // ast-grep-ignore: no-context-turbofmt
                        return Err(err.context(
                            turbofmt!(
                                "NftJsonAsset: cannot handle filepath '{referenced_chunk_path}' \
                                 for {referenced_chunk:?} it is not under the output_root: \
                                 '{output_root_ref}' or the project_root: '{project_root_ref}'",
                            )
                            .await?,
                        ));
                    }
                };

                result.insert(specifier);
            }

            // Apply outputFileTracingIncludes and outputFileTracingExcludes
            // Extract route from chunk path for pattern matching
            if let Some(route) = &this.page_name {
                let mut combined_includes_by_root: FxIndexMap<FileSystemPath, Vec<&str>> =
                    FxIndexMap::default();

                // Process includes
                if let Some(includes_config) = output_file_tracing_includes
                    && let Some(includes_obj) = includes_config.as_object()
                {
                    for (glob_pattern, include_patterns) in includes_obj {
                        // Check if the route matches the glob pattern
                        let glob =
                            Glob::new(glob_pattern.as_str().into(), GlobOptions { contains: true })
                                .await?;
                        if glob.matches(route)
                            && let Some(patterns) = include_patterns.as_array()
                        {
                            for pattern in patterns {
                                if let Some(pattern_str) = pattern.as_str() {
                                    let (glob, root) =
                                        relativize_glob(pattern_str, project_path.clone())?;
                                    combined_includes_by_root
                                        .entry(root)
                                        .or_default()
                                        .push(glob);
                                }
                            }
                        }
                    }
                }

                // Apply includes - find additional files that match the include patterns
                let includes = combined_includes_by_root
                    .into_iter()
                    .map(|(root, globs)| {
                        let glob = Glob::new(
                            format!("{{{}}}", globs.join(",")).into(),
                            GlobOptions { contains: true },
                        );
                        apply_includes(root, glob, &ident_folder_in_project_fs)
                    })
                    .try_join()
                    .await?;

                result.extend(includes.into_iter().flatten());
            }

            let json = json!({
              "version": 1,
              "files": result
            });

            Ok(AssetContent::file(
                FileContent::Content(File::from(json.to_string())).cell(),
            ))
        }
        .instrument(span)
        .await
    }
}

/// The globs defined in the next.config.mjs are relative to the project root.
/// The glob walker in turbopack is somewhat naive so we handle relative path directives first so
/// traversal doesn't need to consider them and can just traverse 'down' the tree.
/// The main alternative is to merge glob evaluation with directory traversal which is what the npm
/// `glob` package does, but this would be a substantial rewrite.
pub(crate) fn relativize_glob(
    glob: &str,
    relative_to: FileSystemPath,
) -> Result<(&str, FileSystemPath)> {
    let mut relative_to = relative_to;
    let mut processed_glob = glob;
    loop {
        if let Some(stripped) = processed_glob.strip_prefix("../") {
            if relative_to.path.is_empty() {
                bail!(
                    "glob '{glob}' is invalid, it has a prefix that navigates out of the project \
                     root"
                );
            }
            relative_to = relative_to.parent();
            processed_glob = stripped;
        } else if let Some(stripped) = processed_glob.strip_prefix("./") {
            processed_glob = stripped;
        } else {
            break;
        }
    }
    Ok((processed_glob, relative_to))
}

/// Walks the asset graph from multiple assets and collect all referenced
/// assets, but filters out all client assets and glob matches.
#[turbo_tasks::function]
pub async fn all_assets_from_entries_filtered(
    entries: Vc<OutputAssets>,
    client_root: Option<FileSystemPath>,
    exclude_glob: Option<Vc<Glob>>,
) -> Result<Vc<OutputAssets>> {
    let exclude_glob = if let Some(exclude_glob) = exclude_glob {
        Some(exclude_glob.await?)
    } else {
        None
    };
    let emit_spans = tracing::enabled!(Level::INFO);
    Ok(Vc::cell(
        AdjacencyMap::new()
            .visit(
                entries
                    .await?
                    .iter()
                    .map(async |asset| {
                        Ok((
                            *asset,
                            if emit_spans {
                                // INVALIDATION: we don't need to invalidate the list of assets when
                                // the span name changes
                                Some(asset.path_string().untracked().await?)
                            } else {
                                None
                            },
                        ))
                    })
                    .try_join()
                    .await?,
                OutputAssetFilteredVisit {
                    client_root,
                    exclude_glob,
                    emit_spans,
                },
            )
            .await
            .completed()?
            .into_postorder_topological()
            .map(|n| n.0)
            .collect(),
    ))
}

#[turbo_tasks::function]
pub async fn error_unexpected_file(
    entries: Vc<OutputAssets>,
    client_root: Option<FileSystemPath>,
    exclude_glob: Option<Vc<Glob>>,
    referenced_chunk: ResolvedVc<Box<dyn OutputAsset>>,
) -> Result<()> {
    let exclude_glob = if let Some(exclude_glob) = exclude_glob {
        Some(exclude_glob.await?)
    } else {
        None
    };
    let emit_spans = tracing::enabled!(Level::INFO);
    let map = AdjacencyMap::new()
        .visit(
            entries
                .await?
                .iter()
                .map(async |asset| {
                    Ok((
                        *asset,
                        if emit_spans {
                            // INVALIDATION: we don't need to invalidate the list of assets when
                            // the span name changes
                            Some(asset.path_string().untracked().await?)
                        } else {
                            None
                        },
                    ))
                })
                .try_join()
                .await?,
            OutputAssetFilteredVisit {
                client_root,
                exclude_glob,
                emit_spans,
            },
        )
        .await
        .completed()?;

    let reversed = map.reversed();

    let mut path = vec![];
    // Find any path from the referenced chunk back to one of the roots
    {
        let mut visited = HashSet::new();
        let mut current = (
            referenced_chunk,
            if emit_spans {
                // INVALIDATION: we don't need to invalidate the list of assets when
                // the span name changes
                Some(referenced_chunk.path_string().untracked().await?)
            } else {
                None
            },
        );
        while let Some((from, _)) = reversed.get(&current).and_then(|mut edges| edges.next()) {
            current = from.clone();
            if !visited.insert(current.0) {
                break;
            }
            path.push(current.0);
        }
    }

    ForbiddenTracedFileIssue {
        file: referenced_chunk,
        path,
    }
    .resolved_cell()
    .emit();

    Ok(())
}

#[turbo_tasks::value(shared)]
struct ForbiddenTracedFileIssue {
    file: ResolvedVc<Box<dyn OutputAsset>>,
    path: Vec<ResolvedVc<Box<dyn OutputAsset>>>,
}

#[async_trait]
#[turbo_tasks::value_impl]
impl Issue for ForbiddenTracedFileIssue {
    fn severity(&self) -> IssueSeverity {
        // Ideally this would be an error, but for now we keep it a warning to avoid breaking
        // existing apps
        IssueSeverity::Warning
    }

    fn stage(&self) -> IssueStage {
        IssueStage::Misc
    }

    async fn file_path(&self) -> Result<FileSystemPath> {
        self.file.path().owned().await
    }

    async fn title(&self) -> Result<StyledString> {
        Ok(StyledString::Text(rcstr!(
            "Encountered unexpected file in NFT list"
        )))
    }

    async fn description(&self) -> Result<Option<StyledString>> {
        let mut stack = vec![
            StyledString::Text(rcstr!(
                "A file was traced that indicates that the whole project was traced \
                 unintentionally. Somewhere in the import trace below, there are:"
            )),
            StyledString::Line(vec![
                StyledString::Text(rcstr!("- filesystem operations (like ")),
                StyledString::Code(rcstr!("path.join")),
                StyledString::Text(rcstr!(", ")),
                StyledString::Code(rcstr!("path.resolve")),
                StyledString::Text(rcstr!(" or ")),
                StyledString::Code(rcstr!("fs.readFile")),
                StyledString::Text(rcstr!("), or")),
            ]),
            StyledString::Line(vec![
                StyledString::Text(rcstr!("- very dynamic requires (like ")),
                StyledString::Code(rcstr!("require('./' + foo)")),
                StyledString::Text(rcstr!(").")),
            ]),
            StyledString::Text(rcstr!("To resolve this, you can")),
            StyledString::Text(rcstr!("- remove them if possible, or")),
            StyledString::Text(rcstr!("- only use them in development, or")),
            StyledString::Line(vec![
                StyledString::Text(rcstr!(
                    "- make sure they are statically scoped to some subfolder: "
                )),
                StyledString::Code(rcstr!("path.join(process.cwd(), 'data', bar)")),
                StyledString::Text(rcstr!(", or")),
            ]),
            StyledString::Line(vec![
                StyledString::Text(rcstr!("- add ignore comments: ")),
                StyledString::Code(rcstr!(
                    "path.join(/*turbopackIgnore: true*/ process.cwd(), bar)"
                )),
            ]),
        ];

        if self.path.len() > 1 {
            stack.extend([
                StyledString::Text(rcstr!("")),
                StyledString::Text(
                    format!(
                        "Output asset trace:\n{}",
                        self.path
                            .iter()
                            .rev()
                            .map(async |a| Ok(format!("  {}", a.path_string().await?)))
                            .try_join()
                            .await?
                            .join("\n")
                    )
                    .into(),
                ),
            ])
        }

        Ok(Some(StyledString::Stack(stack)))
    }
}

struct OutputAssetFilteredVisit {
    client_root: Option<FileSystemPath>,
    exclude_glob: Option<ReadRef<Glob>>,
    emit_spans: bool,
}
impl Visit<(ResolvedVc<Box<dyn OutputAsset>>, Option<ReadRef<RcStr>>)>
    for OutputAssetFilteredVisit
{
    type EdgesIntoIter = Vec<(
        (ResolvedVc<Box<dyn OutputAsset>>, Option<ReadRef<RcStr>>),
        (),
    )>;
    type EdgesFuture = impl Future<Output = Result<Self::EdgesIntoIter>>;

    fn edges(
        &mut self,
        node: &(ResolvedVc<Box<dyn OutputAsset>>, Option<ReadRef<RcStr>>),
    ) -> Self::EdgesFuture {
        let client_root = self.client_root.clone();
        let exclude_glob: Option<ReadRef<Glob>> = self.exclude_glob.clone();
        get_referenced_server_assets(self.emit_spans, node.0, client_root, exclude_glob)
    }

    fn span(
        &mut self,
        node: &(ResolvedVc<Box<dyn OutputAsset>>, Option<ReadRef<RcStr>>),
        _edge: Option<&()>,
    ) -> tracing::Span {
        if let Some(ident) = &node.1 {
            tracing::trace_span!("asset", name = display(ident))
        } else {
            Span::current()
        }
    }
}

/// Computes the list of all chunk children of a given chunk, but filters out all client assets and
/// glob matches.
async fn get_referenced_server_assets(
    emit_spans: bool,
    asset: ResolvedVc<Box<dyn OutputAsset>>,
    client_root: Option<FileSystemPath>,
    exclude_glob: Option<ReadRef<Glob>>,
) -> Result<
    Vec<(
        (ResolvedVc<Box<dyn OutputAsset>>, Option<ReadRef<RcStr>>),
        (),
    )>,
> {
    let refs = asset.references().all_assets().await?;

    refs.iter()
        .map(async |asset| {
            let asset_path = asset.path().await?;

            if let Some(client_root) = &client_root
                && asset_path.is_inside_ref(client_root)
            {
                return Ok(None);
            }

            if exclude_glob
                .as_ref()
                .is_some_and(|g| g.matches(&asset_path.path))
            {
                return Ok(None);
            }

            Ok(Some((
                (
                    *asset,
                    if emit_spans {
                        // INVALIDATION: we don't need to invalidate the list of assets when the
                        // span name changes
                        Some(asset.path_string().untracked().await?)
                    } else {
                        None
                    },
                ),
                (),
            )))
        })
        .try_flat_join()
        .await
}

#[cfg(test)]
mod tests {
    use turbo_tasks::ResolvedVc;
    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
    use turbo_tasks_fs::{FileSystemPath, NullFileSystem};

    use super::*;

    fn create_test_fs_path(path: &str) -> FileSystemPath {
        FileSystemPath {
            fs: ResolvedVc::upcast(NullFileSystem {}.resolved_cell()),
            path: path.into(),
        }
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_relativize_glob_normal_patterns() {
        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
            BackendOptions::default(),
            noop_backing_storage(),
        ));
        tt.run_once(async {
            // Test normal glob patterns without relative prefixes
            let base_path = create_test_fs_path("project/src");

            let (glob, path) = relativize_glob("*.js", base_path.clone()).unwrap();
            assert_eq!(glob, "*.js");
            assert_eq!(path.path.as_str(), "project/src");

            let (glob, path) = relativize_glob("components/**/*.tsx", base_path.clone()).unwrap();
            assert_eq!(glob, "components/**/*.tsx");
            assert_eq!(path.path.as_str(), "project/src");

            let (glob, path) = relativize_glob("lib/utils.ts", base_path.clone()).unwrap();
            assert_eq!(glob, "lib/utils.ts");
            assert_eq!(path.path.as_str(), "project/src");
            Ok(())
        })
        .await
        .unwrap();
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_relativize_glob_current_directory_prefix() {
        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
            BackendOptions::default(),
            noop_backing_storage(),
        ));
        tt.run_once(async {
            let base_path = create_test_fs_path("project/src");

            // Single ./ prefix
            let (glob, path) = relativize_glob("./components/*.tsx", base_path.clone()).unwrap();
            assert_eq!(glob, "components/*.tsx");
            assert_eq!(path.path.as_str(), "project/src");

            // Multiple ./ prefixes
            let (glob, path) = relativize_glob("././utils.js", base_path.clone()).unwrap();
            assert_eq!(glob, "utils.js");
            assert_eq!(path.path.as_str(), "project/src");

            // ./ with complex glob
            let (glob, path) = relativize_glob("./lib/**/*.{js,ts}", base_path.clone()).unwrap();
            assert_eq!(glob, "lib/**/*.{js,ts}");
            assert_eq!(path.path.as_str(), "project/src");
            Ok(())
        })
        .await
        .unwrap();
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_relativize_glob_parent_directory_navigation() {
        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
            BackendOptions::default(),
            noop_backing_storage(),
        ));
        tt.run_once(async {
            let base_path = create_test_fs_path("project/src/components");

            // Single ../ prefix
            let (glob, path) = relativize_glob("../utils/*.js", base_path.clone()).unwrap();
            assert_eq!(glob, "utils/*.js");
            assert_eq!(path.path.as_str(), "project/src");

            // Multiple ../ prefixes
            let (glob, path) = relativize_glob("../../lib/*.ts", base_path.clone()).unwrap();
            assert_eq!(glob, "lib/*.ts");
            assert_eq!(path.path.as_str(), "project");

            // Complex navigation with glob
            let (glob, path) =
                relativize_glob("../../../external/**/*.json", base_path.clone()).unwrap();
            assert_eq!(glob, "external/**/*.json");
            assert_eq!(path.path.as_str(), "");
            Ok(())
        })
        .await
        .unwrap();
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_relativize_glob_mixed_prefixes() {
        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
            BackendOptions::default(),
            noop_backing_storage(),
        ));
        tt.run_once(async {
            let base_path = create_test_fs_path("project/src/components");

            // ../ followed by ./
            let (glob, path) = relativize_glob(".././utils/*.js", base_path.clone()).unwrap();
            assert_eq!(glob, "utils/*.js");
            assert_eq!(path.path.as_str(), "project/src");

            // ./ followed by ../
            let (glob, path) = relativize_glob("./../lib/*.ts", base_path.clone()).unwrap();
            assert_eq!(glob, "lib/*.ts");
            assert_eq!(path.path.as_str(), "project/src");

            // Multiple mixed prefixes
            let (glob, path) =
                relativize_glob("././../.././external/*.json", base_path.clone()).unwrap();
            assert_eq!(glob, "external/*.json");
            assert_eq!(path.path.as_str(), "project");
            Ok(())
        })
        .await
        .unwrap();
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_relativize_glob_error_navigation_out_of_root() {
        let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
            BackendOptions::default(),
            noop_backing_storage(),
        ));
        tt.run_once(async {
            // Test navigating out of project root with empty path
            let empty_path = create_test_fs_path("");
            let result = relativize_glob("../outside.js", empty_path);
            assert!(result.is_err());
            assert!(
                result
                    .unwrap_err()
                    .to_string()
                    .contains("navigates out of the project root")
            );

            // Test navigating too far up from a shallow path
            let shallow_path = create_test_fs_path("project");
            let result = relativize_glob("../../outside.js", shallow_path);
            assert!(result.is_err());
            assert!(
                result
                    .unwrap_err()
                    .to_string()
                    .contains("navigates out of the project root")
            );

            // Test multiple ../ that would go out of root
            let base_path = create_test_fs_path("a/b");
            let result = relativize_glob("../../../outside.js", base_path);
            assert!(result.is_err());
            assert!(
                result
                    .unwrap_err()
                    .to_string()
                    .contains("navigates out of the project root")
            );
            Ok(())
        })
        .await
        .unwrap();
    }
}
Quest for Codev2.0.0
/
SIGN IN